From e6e1954c3e799d0a743d6e8776e6bad4f87d3cfe Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 19:27:55 +0000 Subject: [PATCH 01/16] Implement vanilla shield blocking --- server/block/explosion.go | 4 + server/block/tnt.go | 50 ++- server/block/tnt_test.go | 61 +++ server/entity/damage.go | 19 +- server/entity/ent.go | 8 + server/entity/projectile.go | 63 ++- server/entity/projectile_test.go | 150 +++++++ server/entity/register.go | 1 + server/entity/tnt.go | 28 +- server/item/register.go | 1 + server/item/shield.go | 32 ++ server/item/shield_test.go | 37 ++ server/player/player.go | 85 +++- server/player/shield.go | 258 ++++++++++++ server/player/shield_test.go | 371 ++++++++++++++++++ server/session/entity_metadata.go | 7 + server/session/handler_player_auth_input.go | 30 ++ .../session/handler_player_auth_input_test.go | 54 +++ server/session/world.go | 2 + server/world/entity.go | 11 +- server/world/sound/item.go | 3 + 21 files changed, 1234 insertions(+), 41 deletions(-) create mode 100644 server/block/tnt_test.go create mode 100644 server/entity/projectile_test.go create mode 100644 server/item/shield.go create mode 100644 server/item/shield_test.go create mode 100644 server/player/shield.go create mode 100644 server/player/shield_test.go create mode 100644 server/session/handler_player_auth_input_test.go diff --git a/server/block/explosion.go b/server/block/explosion.go index 1070bd961..3c5677b09 100644 --- a/server/block/explosion.go +++ b/server/block/explosion.go @@ -30,6 +30,10 @@ type ExplosionConfig struct { // the item drop chance is 1/Size. If negative, no items will be dropped by // the explosion. If set to 1 or higher, all items are dropped. ItemDropChance float64 + // UnblockableByShield specifies if the explosion damage should not be blockable by shields. + UnblockableByShield bool + // Source is the entity that caused the explosion, if known. + Source world.Entity // Sound is the sound to play when the explosion is created. If set to nil, this will default to the sound of a // regular explosion. diff --git a/server/block/tnt.go b/server/block/tnt.go index 4606f4512..ba13c8077 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -19,7 +19,7 @@ type TNT struct { // ProjectileHit ... func (t TNT) ProjectileHit(pos cube.Pos, tx *world.Tx, e world.Entity, _ cube.Face) { if f, ok := e.(flammableEntity); ok && f.OnFireDuration() > 0 { - t.Ignite(pos, tx, nil) + spawnTnt(pos, tx, time.Second*4, tntIgnitionSourceHandle(e)) } } @@ -27,7 +27,7 @@ func (t TNT) ProjectileHit(pos cube.Pos, tx *world.Tx, e world.Entity, _ cube.Fa func (t TNT) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx *item.UseContext) bool { held, _ := u.HeldItems() if _, ok := held.Enchantment(enchantment.FireAspect); ok { - t.Ignite(pos, tx, nil) + t.Ignite(pos, tx, u) ctx.DamageItem(1) return true } @@ -35,14 +35,14 @@ func (t TNT) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx } // Ignite ... -func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, _ world.Entity) bool { - spawnTnt(pos, tx, time.Second*4) +func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, source world.Entity) bool { + spawnTnt(pos, tx, time.Second*4, entityHandle(source)) return true } // Explode ... -func (t TNT) Explode(_ mgl64.Vec3, pos cube.Pos, tx *world.Tx, _ ExplosionConfig) { - spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2)))) +func (t TNT) Explode(_ mgl64.Vec3, pos cube.Pos, tx *world.Tx, c ExplosionConfig) { + spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2))), tntExplosionSourceHandle(c)) } // BreakInfo ... @@ -66,9 +66,43 @@ func (t TNT) EncodeBlock() (name string, properties map[string]interface{}) { } // spawnTnt creates a new TNT entity at the given position with the given fuse duration. -func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration) { +type ownerEntity interface { + ProjectileOwner() *world.EntityHandle +} + +func tntIgnitionSourceHandle(source world.Entity) *world.EntityHandle { + if source == nil { + return nil + } + if o, ok := source.(ownerEntity); ok { + if owner := o.ProjectileOwner(); owner != nil { + return owner + } + } + return source.H() +} + +func tntExplosionSourceHandle(c ExplosionConfig) *world.EntityHandle { + return entityHandle(c.Source) +} + +func entityHandle(e world.Entity) *world.EntityHandle { + if e == nil { + return nil + } + return e.H() +} + +func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration, source *world.EntityHandle) { tx.PlaySound(pos.Vec3Centre(), sound.TNT{}) tx.SetBlock(pos, nil, nil) opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} - tx.AddEntity(tx.World().EntityRegistry().Config().TNT(opts, fuse)) + conf := tx.World().EntityRegistry().Config() + if source != nil && conf.TNTWithSource != nil { + if e, ok := source.Entity(tx); ok { + tx.AddEntity(conf.TNTWithSource(opts, fuse, e)) + return + } + } + tx.AddEntity(conf.TNT(opts, fuse)) } diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go new file mode 100644 index 000000000..83352f73a --- /dev/null +++ b/server/block/tnt_test.go @@ -0,0 +1,61 @@ +package block + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +type tntSourceEntity struct { + h *world.EntityHandle + owner *world.EntityHandle +} + +func (e tntSourceEntity) Close() error { return nil } +func (e tntSourceEntity) H() *world.EntityHandle { return e.h } +func (e tntSourceEntity) Position() mgl64.Vec3 { return mgl64.Vec3{} } +func (e tntSourceEntity) Rotation() cube.Rotation { + return cube.Rotation{} +} +func (e tntSourceEntity) ProjectileOwner() *world.EntityHandle { return e.owner } + +type tntTestEntityType struct{} + +func (tntTestEntityType) Open(*world.Tx, *world.EntityHandle, *world.EntityData) world.Entity { + return nil +} +func (tntTestEntityType) EncodeEntity() string { return "dragonfly:test_entity" } +func (tntTestEntityType) BBox(world.Entity) cube.BBox { return cube.Box(0, 0, 0, 0, 0, 0) } +func (tntTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} +func (tntTestEntityType) EncodeNBT(*world.EntityData) map[string]any { return nil } +func (tntTestEntityType) Apply(data *world.EntityData) {} +func newTNTTestHandle() *world.EntityHandle { + return world.EntitySpawnOpts{}.New(tntTestEntityType{}, tntTestEntityType{}) +} + +func TestTNTIgnitionSourcePrefersProjectileOwner(t *testing.T) { + owner := newTNTTestHandle() + projectile := tntSourceEntity{h: newTNTTestHandle(), owner: owner} + + if got := tntIgnitionSourceHandle(projectile); got != owner { + t.Fatalf("expected TNT ignition source to use projectile owner handle %v, got %v", owner, got) + } +} + +func TestTNTIgnitionSourceFallsBackToIgnitingEntity(t *testing.T) { + projectile := tntSourceEntity{h: newTNTTestHandle()} + + if got := tntIgnitionSourceHandle(projectile); got != projectile.H() { + t.Fatalf("expected TNT ignition source to fall back to igniting entity handle %v, got %v", projectile.H(), got) + } +} + +func TestTNTExplosionSourceUsesExplosionConfigSource(t *testing.T) { + source := tntSourceEntity{h: newTNTTestHandle()} + + if got := tntExplosionSourceHandle(ExplosionConfig{Source: source}); got != source.H() { + t.Fatalf("expected chained TNT source to use explosion config source handle %v, got %v", source.H(), got) + } +} diff --git a/server/entity/damage.go b/server/entity/damage.go index 110378661..318d4eabb 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -4,8 +4,14 @@ import ( "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" ) +// ShieldBlockHandler handles a projectile damage source being blocked by a shield. +type ShieldBlockHandler interface { + HandleShieldBlock() +} + type ( // AttackDamageSource is used for damage caused by other entities, for // example when a player attacks another player. @@ -42,10 +48,21 @@ type ( // Projectile and Owner are the world.Entity that dealt the damage and // the one that fired the projectile respectively. Projectile, Owner world.Entity + // ShieldBlockHandler is called if the projectile damage is blocked by a shield. + ShieldBlockHandler ShieldBlockHandler } // ExplosionDamageSource is used for damage caused by an explosion. - ExplosionDamageSource struct{} + ExplosionDamageSource struct { + // Origin is the position from which the explosion damage originated. + Origin mgl64.Vec3 + // HasOrigin is true if Origin is a meaningful explosion source position. + HasOrigin bool + // BlockableByShield is true if the explosion damage may be blocked by a shield. + BlockableByShield bool + // Source is the entity that caused the explosion, if known. + Source world.Entity + } ) func (FallDamageSource) ReducedByArmour() bool { return false } diff --git a/server/entity/ent.go b/server/entity/ent.go index e3bb889fb..effca6d52 100644 --- a/server/entity/ent.go +++ b/server/entity/ent.go @@ -40,6 +40,14 @@ func (e *Ent) Behaviour() Behaviour { return e.data.Data.(Behaviour) } +// ProjectileOwner returns the entity that owns this Ent, if its Behaviour tracks one. +func (e *Ent) ProjectileOwner() *world.EntityHandle { + if owner, ok := e.Behaviour().(interface{ Owner() *world.EntityHandle }); ok { + return owner.Owner() + } + return nil +} + // Explode propagates the explosion behaviour of the underlying Behaviour. func (e *Ent) Explode(src mgl64.Vec3, impact float64, conf block.ExplosionConfig) { if expl, ok := e.Behaviour().(interface { diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 1d11f6a2c..33fedf9ab 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -159,17 +159,13 @@ func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { return m } - for i := 0; i < lt.conf.ParticleCount; i++ { - tx.AddParticle(result.Position(), lt.conf.Particle) - } - if lt.conf.Sound != nil { - tx.PlaySound(result.Position(), lt.conf.Sound) - } - + deflected := false switch r := result.(type) { case trace.EntityResult: if l, ok := r.Entity().(Living); ok && lt.conf.Damage >= 0 { - lt.hitEntity(l, e, vel) + if lt.hitEntity(l, e, vel) { + deflected = true + } } case trace.BlockResult: bpos := r.BlockPosition() @@ -177,10 +173,19 @@ func (lt *ProjectileBehaviour) Tick(e *Ent, tx *world.Tx) *Movement { h.ProjectileHit(bpos, tx, e, r.Face()) } if lt.conf.SurviveBlockCollision { + lt.emitHitEffects(tx, result) lt.hitBlockSurviving(e, r, m, tx) return m } } + if deflected { + m.pos = e.Position() + m.vel = e.Velocity() + m.dpos = m.pos.Sub(result.Position()) + m.dvel = m.vel.Sub(vel) + return m + } + lt.emitHitEffects(tx, result) if lt.conf.Hit != nil { lt.conf.Hit(e, tx, result) } @@ -257,14 +262,21 @@ func (lt *ProjectileBehaviour) hitBlockSurviving(e *Ent, r trace.BlockResult, m // hitEntity is called when a projectile hits a Living. It deals damage to the // entity and knocks it back. Additionally, it applies any potion effects and // fire if applicable. -func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) { - owner, _ := lt.conf.Owner.Entity(e.tx) - src := ProjectileDamageSource{Projectile: e, Owner: owner} +func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) bool { + var owner world.Entity + if lt.conf.Owner != nil { + owner, _ = lt.conf.Owner.Entity(e.tx) + } + blockHandler := &projectileShieldBlockHandler{} + src := ProjectileDamageSource{Projectile: e, Owner: owner, ShieldBlockHandler: blockHandler} dmg := math.Ceil(lt.conf.Damage * vel.Len()) if lt.conf.Critical { dmg += rand.Float64() * dmg / 2 } - if _, vulnerable := l.Hurt(dmg, src); vulnerable { + if _, vulnerable := l.Hurt(dmg, src); blockHandler.blocked { + lt.deflect(e, vel) + return true + } else if vulnerable { l.KnockBack(l.Position().Sub(vel), 0.45+lt.conf.KnockBackForceAddend, 0.3608+lt.conf.KnockBackHeightAddend) for _, eff := range lt.conf.Potion.Effects() { @@ -278,6 +290,33 @@ func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) { flammable.SetOnFire(time.Second * 5) } } + return false +} + +func (lt *ProjectileBehaviour) emitHitEffects(tx *world.Tx, result trace.Result) { + for i := 0; i < lt.conf.ParticleCount; i++ { + tx.AddParticle(result.Position(), lt.conf.Particle) + } + if lt.conf.Sound != nil { + tx.PlaySound(result.Position(), lt.conf.Sound) + } +} + +type projectileShieldBlockHandler struct { + blocked bool +} + +func (h *projectileShieldBlockHandler) HandleShieldBlock() { + h.blocked = true +} + +func (lt *ProjectileBehaviour) deflect(e *Ent, vel mgl64.Vec3) { + if vel.Len() == 0 { + return + } + reflected := vel.Mul(-1) + e.SetVelocity(reflected) + e.data.Pos = e.Position().Add(reflected.Normalize().Mul(0.05)) } // tickMovement ticks the movement of a projectile. It updates the position and diff --git a/server/entity/projectile_test.go b/server/entity/projectile_test.go new file mode 100644 index 000000000..5da260b8c --- /dev/null +++ b/server/entity/projectile_test.go @@ -0,0 +1,150 @@ +package entity + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/block/cube/trace" + "github.com/df-mc/dragonfly/server/entity/effect" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +type projectileShieldTarget struct { + pos mgl64.Vec3 + h *world.EntityHandle + blocked bool + vulnerable bool +} + +func (t *projectileShieldTarget) Close() error { return nil } +func (t *projectileShieldTarget) H() *world.EntityHandle { return t.h } +func (t *projectileShieldTarget) Position() mgl64.Vec3 { return t.pos } +func (t *projectileShieldTarget) Rotation() cube.Rotation { return cube.Rotation{} } +func (t *projectileShieldTarget) Health() float64 { return 20 } +func (t *projectileShieldTarget) MaxHealth() float64 { return 20 } +func (t *projectileShieldTarget) SetMaxHealth(float64) {} +func (t *projectileShieldTarget) Dead() bool { return false } +func (t *projectileShieldTarget) Heal(float64, world.HealingSource) {} +func (t *projectileShieldTarget) KnockBack(mgl64.Vec3, float64, float64) {} +func (t *projectileShieldTarget) Velocity() mgl64.Vec3 { return mgl64.Vec3{} } +func (t *projectileShieldTarget) SetVelocity(mgl64.Vec3) {} +func (t *projectileShieldTarget) AddEffect(effect.Effect) {} +func (t *projectileShieldTarget) RemoveEffect(effect.Type) {} +func (t *projectileShieldTarget) Effects() []effect.Effect { return nil } +func (t *projectileShieldTarget) Speed() float64 { return 0 } +func (t *projectileShieldTarget) SetSpeed(float64) {} + +func (t *projectileShieldTarget) Hurt(_ float64, src world.DamageSource) (float64, bool) { + if s, ok := src.(ProjectileDamageSource); ok && t.blocked && s.ShieldBlockHandler != nil { + s.ShieldBlockHandler.HandleShieldBlock() + } + return 0, t.vulnerable +} + +type projectileShieldTargetConfig struct { + blocked bool + vulnerable bool +} + +func (c projectileShieldTargetConfig) Apply(data *world.EntityData) { + data.Data = c +} + +type projectileShieldTargetType struct{} + +func (projectileShieldTargetType) Open(_ *world.Tx, h *world.EntityHandle, data *world.EntityData) world.Entity { + conf := data.Data.(projectileShieldTargetConfig) + return &projectileShieldTarget{pos: data.Pos, h: h, blocked: conf.blocked, vulnerable: conf.vulnerable} +} +func (projectileShieldTargetType) EncodeEntity() string { return "dragonfly:shield_target" } +func (projectileShieldTargetType) BBox(world.Entity) cube.BBox { + return cube.Box(-0.3, 0, -0.3, 0.3, 1.8, 0.3) +} +func (projectileShieldTargetType) DecodeNBT(map[string]any, *world.EntityData) {} +func (projectileShieldTargetType) EncodeNBT(*world.EntityData) map[string]any { return nil } + +type projectileTestParticle struct { + count *int +} + +func (p projectileTestParticle) Spawn(*world.World, mgl64.Vec3) { + (*p.count)++ +} + +type projectileTestSound struct { + count *int +} + +func (s projectileTestSound) Play(*world.World, mgl64.Vec3) { + (*s.count)++ +} + +func TestProjectileDeflectsAfterShieldBlock(t *testing.T) { + pos := mgl64.Vec3{0, 0, 1} + projectile := &Ent{data: &world.EntityData{Pos: pos}} + behaviour := &ProjectileBehaviour{conf: ProjectileBehaviourConfig{Damage: 2}} + velocity := mgl64.Vec3{0, 0, -1} + + blocked := behaviour.hitEntity(&projectileShieldTarget{blocked: true}, projectile, velocity) + if !blocked { + t.Fatal("expected shield-blocked projectile hit to be handled as a deflection") + } + if got, want := projectile.Velocity(), velocity.Mul(-1); got != want { + t.Fatalf("expected deflected projectile velocity %v, got %v", want, got) + } + if !projectile.Position().Sub(pos).Normalize().ApproxEqual(projectile.Velocity().Normalize()) { + t.Fatalf("expected projectile to move away from blocker after deflection, position changed from %v to %v with velocity %v", pos, projectile.Position(), projectile.Velocity()) + } +} + +func TestProjectileDeflectsZeroDamageShieldBlock(t *testing.T) { + pos := mgl64.Vec3{0, 0, 1} + projectile := &Ent{data: &world.EntityData{Pos: pos}} + behaviour := &ProjectileBehaviour{conf: ProjectileBehaviourConfig{Damage: 0}} + velocity := mgl64.Vec3{0, 0, -1} + + blocked := behaviour.hitEntity(&projectileShieldTarget{blocked: true}, projectile, velocity) + if !blocked { + t.Fatal("expected zero damage shield-blocked projectile hit to be handled as a deflection") + } + if got, want := projectile.Velocity(), velocity.Mul(-1); got != want { + t.Fatalf("expected deflected projectile velocity %v, got %v", want, got) + } +} + +func TestProjectileDeflectionSkipsHitCallback(t *testing.T) { + w := world.Config{Entities: world.EntityRegistryConfig{}.New([]world.EntityType{SnowballType, projectileShieldTargetType{}})}.New() + defer func() { + _ = w.Close() + }() + var particles, sounds int + hit := false + projectile := world.EntitySpawnOpts{ + Position: mgl64.Vec3{0, 0.5, 0}, + Velocity: mgl64.Vec3{0, 0, 1}, + }.New(SnowballType, ProjectileBehaviourConfig{ + Damage: 0, + Particle: projectileTestParticle{count: &particles}, + ParticleCount: 1, + Sound: projectileTestSound{count: &sounds}, + Hit: func(*Ent, *world.Tx, trace.Result) { + hit = true + }, + }) + target := world.EntitySpawnOpts{Position: mgl64.Vec3{0, 0, 0.8}}.New(projectileShieldTargetType{}, projectileShieldTargetConfig{blocked: true}) + + <-w.Exec(func(tx *world.Tx) { + tx.AddEntity(target) + tx.AddEntity(projectile).(*Ent).Tick(tx, 0) + }) + if hit { + t.Fatal("expected shield-deflected projectile not to run hit callback") + } + if particles != 0 { + t.Fatalf("expected shield-deflected projectile not to spawn hit particles, got %v", particles) + } + if sounds != 0 { + t.Fatalf("expected shield-deflected projectile not to play hit sound, got %v", sounds) + } +} diff --git a/server/entity/register.go b/server/entity/register.go index e9c54922e..bcdf5bdc3 100644 --- a/server/entity/register.go +++ b/server/entity/register.go @@ -29,6 +29,7 @@ var DefaultRegistry = conf.New([]world.EntityType{ var conf = world.EntityRegistryConfig{ TNT: NewTNT, + TNTWithSource: NewTNTWithSource, Egg: NewEgg, Snowball: NewSnowball, BottleOfEnchanting: NewBottleOfEnchanting, diff --git a/server/entity/tnt.go b/server/entity/tnt.go index b151dd19a..9a71ec5d7 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -13,8 +13,20 @@ import ( // NewTNT creates a new primed TNT entity. func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { + return newTNTWithSourceHandle(opts, fuse, nil) +} + +// NewTNTWithSource creates a new primed TNT entity with the entity that caused it to ignite. +func NewTNTWithSource(opts world.EntitySpawnOpts, fuse time.Duration, source world.Entity) *world.EntityHandle { + return newTNTWithSourceHandle(opts, fuse, entityHandle(source)) +} + +func newTNTWithSourceHandle(opts world.EntitySpawnOpts, fuse time.Duration, source *world.EntityHandle) *world.EntityHandle { conf := tntConf conf.ExistenceDuration = fuse + conf.Expire = func(e *Ent, tx *world.Tx) { + explodeTNT(e, tx, source) + } if opts.Velocity.Len() == 0 { angle := rand.Float64() * math.Pi * 2 opts.Velocity = mgl64.Vec3{-math.Sin(angle) * 0.02, 0.1, -math.Cos(angle) * 0.02} @@ -22,15 +34,25 @@ func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle return opts.New(TNTType, conf) } +func entityHandle(e world.Entity) *world.EntityHandle { + if e == nil { + return nil + } + return e.H() +} + var tntConf = PassiveBehaviourConfig{ Gravity: 0.04, Drag: 0.02, - Expire: explodeTNT, + Expire: func(e *Ent, tx *world.Tx) { + explodeTNT(e, tx, nil) + }, } // explodeTNT creates an explosion at the position of e. -func explodeTNT(e *Ent, tx *world.Tx) { - block.ExplosionConfig{ItemDropChance: 1}.Explode(tx, e.Position()) +func explodeTNT(e *Ent, tx *world.Tx, source *world.EntityHandle) { + sourceEntity, ok := source.Entity(tx) + block.ExplosionConfig{ItemDropChance: 1, UnblockableByShield: !ok, Source: sourceEntity}.Explode(tx, e.Position()) } // TNTType is a world.EntityType implementation for TNT. diff --git a/server/item/register.go b/server/item/register.go index 786af8575..8111b08ff 100644 --- a/server/item/register.go +++ b/server/item/register.go @@ -113,6 +113,7 @@ func init() { world.RegisterItem(Salmon{}) world.RegisterItem(Scute{}) world.RegisterItem(Shears{}) + world.RegisterItem(Shield{}) world.RegisterItem(ShulkerShell{}) world.RegisterItem(Slimeball{}) world.RegisterItem(Snowball{}) diff --git a/server/item/shield.go b/server/item/shield.go new file mode 100644 index 000000000..93247f04d --- /dev/null +++ b/server/item/shield.go @@ -0,0 +1,32 @@ +package item + +// Shield is a defensive item that can block incoming attacks while held. +type Shield struct{} + +// DurabilityInfo ... +func (Shield) DurabilityInfo() DurabilityInfo { + return DurabilityInfo{ + MaxDurability: 337, + BrokenItem: simpleItem(Stack{}), + } +} + +// RepairableBy ... +func (Shield) RepairableBy(i Stack) bool { + return toolTierRepairable(ToolTierWood)(i) +} + +// MaxCount always returns 1. +func (Shield) MaxCount() int { + return 1 +} + +// OffHand ... +func (Shield) OffHand() bool { + return true +} + +// EncodeItem ... +func (Shield) EncodeItem() (name string, meta int16) { + return "minecraft:shield", 0 +} diff --git a/server/item/shield_test.go b/server/item/shield_test.go new file mode 100644 index 000000000..4db6b60e1 --- /dev/null +++ b/server/item/shield_test.go @@ -0,0 +1,37 @@ +package item + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/world" +) + +func TestShieldProperties(t *testing.T) { + shield := Shield{} + if shield.MaxCount() != 1 { + t.Fatalf("expected shield max count 1, got %v", shield.MaxCount()) + } + if !shield.OffHand() { + t.Fatal("expected shield to be valid in the off hand") + } + + info := shield.DurabilityInfo() + if info.MaxDurability != 337 { + t.Fatalf("expected shield max durability 337, got %v", info.MaxDurability) + } + + name, meta := shield.EncodeItem() + if name != "minecraft:shield" || meta != 0 { + t.Fatalf("expected minecraft:shield/0 encoding, got %v/%v", name, meta) + } +} + +func TestShieldRegistered(t *testing.T) { + it, ok := world.ItemByName("minecraft:shield", 0) + if !ok { + t.Fatal("expected minecraft:shield to be registered") + } + if _, ok := it.(Shield); !ok { + t.Fatalf("expected registered item to be Shield, got %T", it) + } +} diff --git a/server/player/player.go b/server/player/player.go index d3c535fc3..db16f3b1b 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -56,12 +56,12 @@ type playerData struct { heldSlot *uint32 sneaking, sprinting, swimming, gliding, crawling, flying, - invisible, immobile, onGround, usingItem bool + invisible, immobile, onGround, usingItem, shieldBlockingInput, shieldBlockingCached bool sleeping bool sleepPos cube.Pos - usingSince time.Time + usingSince, shieldBlockingSince time.Time glideTicks int64 fireTicks int64 @@ -581,8 +581,13 @@ func (p *Player) fall(distance float64) { // final damage dealt to the Player and if the Player was vulnerable to this // kind of damage. func (p *Player) Hurt(dmg float64, src world.DamageSource) (float64, bool) { + finalDamage, vulnerable, _ := p.hurt(dmg, src) + return finalDamage, vulnerable +} + +func (p *Player) hurt(dmg float64, src world.DamageSource) (float64, bool, bool) { if _, ok := p.Effect(effect.FireResistance); (ok && src.Fire()) || p.Dead() || !p.GameMode().AllowsTakingDamage() || dmg < 0 { - return 0, false + return 0, false, false } totalDamage := p.FinalDamageFrom(dmg, src) damageLeft := totalDamage @@ -590,14 +595,18 @@ func (p *Player) Hurt(dmg float64, src world.DamageSource) (float64, bool) { immune := time.Now().Before(p.immuneUntil) if immune { if damageLeft -= p.lastDamage; damageLeft <= 0 { - return 0, false + return 0, false, false } } immunity := time.Second / 2 ctx := event.C(p) + damageBeforeHandler := damageLeft if p.Handler().HandleHurt(ctx, &damageLeft, immune, &immunity, src); ctx.Cancelled() { - return 0, false + return 0, false, false + } + if shouldAttemptShieldBlock(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { + return 0, false, true } p.setAttackImmunity(immunity, totalDamage) @@ -611,11 +620,11 @@ func (p *Player) Hurt(dmg float64, src world.DamageSource) (float64, bool) { if _, ok := offHand.Item().(item.Totem); ok { p.applyTotemEffects() p.SetHeldItems(hand, offHand.Grow(-1)) - return 0, false + return 0, false, false } else if _, ok := hand.Item().(item.Totem); ok { p.applyTotemEffects() p.SetHeldItems(hand.Grow(-1), offHand) - return 0, false + return 0, false, false } } @@ -653,7 +662,7 @@ func (p *Player) Hurt(dmg float64, src world.DamageSource) (float64, bool) { if p.Dead() { p.kill(src) } - return totalDamage, true + return totalDamage, true, false } // applyTotemEffects is an unexported function that is used to handle totem effects. @@ -692,8 +701,15 @@ func (p *Player) FinalDamageFrom(dmg float64, src world.DamageSource) float64 { // Explode ... func (p *Player) Explode(explosionPos mgl64.Vec3, impact float64, c block.ExplosionConfig) { diff := p.Position().Sub(explosionPos) - p.Hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), entity.ExplosionDamageSource{}) - p.knockBack(explosionPos, impact, diff[1]/diff.Len()*impact) + _, _, shieldBlocked := p.hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), entity.ExplosionDamageSource{ + Origin: explosionPos, + HasOrigin: true, + BlockableByShield: !c.UnblockableByShield, + Source: c.Source, + }) + if !shieldBlocked { + p.knockBack(explosionPos, impact, diff[1]/diff.Len()*impact) + } } // SetAbsorption sets the absorption health of a player. This extra health shows as golden hearts and do not @@ -1043,6 +1059,7 @@ func (p *Player) StartSneaking() { p.StopSprinting() } p.sneaking = true + p.updateShieldBlockingState(time.Now()) p.updateState() } @@ -1062,6 +1079,7 @@ func (p *Player) StopSneaking() { return } p.sneaking = false + p.updateShieldBlockingState(time.Now()) p.updateState() } @@ -1380,6 +1398,9 @@ func (p *Player) HeldItems() (mainHand, offHand item.Stack) { func (p *Player) SetHeldItems(mainHand, offHand item.Stack) { _ = p.inv.SetItem(int(*p.heldSlot), mainHand) _ = p.offHand.SetItem(0, offHand) + if changed := p.updateShieldBlockingState(time.Now()); changed && p.tx != nil { + p.updateState() + } } // SetHeldSlot updates the held slot of the player to the slot provided. The @@ -1405,10 +1426,14 @@ func (p *Player) SetHeldSlot(to int) error { } *p.heldSlot = uint32(to) p.usingItem = false + shieldChanged := p.updateShieldBlockingState(time.Now()) for _, viewer := range p.viewers() { viewer.ViewEntityItems(p) } + if shieldChanged { + p.updateState() + } p.session().SendHeldSlot(to, p, false) return nil } @@ -1453,6 +1478,10 @@ func (p *Player) GameMode() world.GameMode { // HasCooldown returns true if the item passed has an active cooldown, meaning it currently cannot be used again. If the // world.Item passed is nil, HasCooldown always returns false. func (p *Player) HasCooldown(item world.Item) bool { + return p.hasCooldownAt(item, time.Now()) +} + +func (p *Player) hasCooldownAt(item world.Item, now time.Time) bool { if item == nil { return false } @@ -1461,7 +1490,7 @@ func (p *Player) HasCooldown(item world.Item) bool { if !ok { return false } - if time.Now().After(otherTime) { + if now.After(otherTime) { delete(p.cooldowns, name) return false } @@ -1470,12 +1499,21 @@ func (p *Player) HasCooldown(item world.Item) bool { // SetCooldown sets a cooldown for an item. If the world.Item passed is nil, nothing happens. func (p *Player) SetCooldown(item world.Item, cooldown time.Duration) { + p.setCooldown(item, cooldown, true) +} + +func (p *Player) setCooldown(item world.Item, cooldown time.Duration, updateShieldState bool) { if item == nil { return } name, _ := item.EncodeItem() p.cooldowns[name] = time.Now().Add(cooldown) p.session().ViewItemCooldown(item, cooldown) + if name == shieldItemName && updateShieldState { + if changed := p.resetShieldBlocking(); changed && p.tx != nil { + p.updateState() + } + } } // UseItem uses the item currently held in the player's main hand in the air. Generally, nothing happens, @@ -1492,6 +1530,9 @@ func (p *Player) UseItem() { } i, left := p.HeldItems() it := i.Item() + if p.StartShieldBlockingInput() { + return + } if cd, ok := it.(item.Cooldown); ok { p.SetCooldown(it, cd.Cooldown()) @@ -1577,6 +1618,9 @@ func (p *Player) UseItem() { // ReleaseItem either aborts the using of the item or finished it, depending on the time that elapsed since // the item started being used. func (p *Player) ReleaseItem() { + if p.shieldBlockingInput { + p.SetShieldBlockingInput(false) + } if !p.usingItem || !p.canRelease() || !p.GameMode().AllowsInteraction() { p.usingItem = false return @@ -1653,7 +1697,18 @@ func (p *Player) useDuration() time.Duration { // UsingItem checks if the Player is currently using an item. True is returned if the Player is currently eating an // item or using it over a longer duration such as when using a bow. func (p *Player) UsingItem() bool { - return p.usingItem + return p.usingItem || !p.shieldBlockingSince.IsZero() +} + +// SetShieldBlockingInput updates whether the player is holding the control that raises shields. +func (p *Player) SetShieldBlockingInput(down bool) { + if p.shieldBlockingInput == down { + return + } + p.shieldBlockingInput = down + if changed := p.updateShieldBlockingState(time.Now()); changed && p.tx != nil { + p.updateState() + } } // UseItemOnBlock uses the item held in the main hand of the player on a block at the position passed. The @@ -2557,11 +2612,15 @@ func (p *Player) Tick(tx *world.Tx, current int64) { p.ContinueBreaking(p.breakingFace) } + now := time.Now() for it, ti := range p.cooldowns { - if time.Now().After(ti) { + if now.After(ti) { delete(p.cooldowns, it) } } + if p.updateShieldBlockingState(now) { + p.updateState() + } p.session().SendDebugShapes(tx.World().Dimension()) p.session().SendHudUpdates() diff --git a/server/player/shield.go b/server/player/shield.go new file mode 100644 index 000000000..02c2ea4ee --- /dev/null +++ b/server/player/shield.go @@ -0,0 +1,258 @@ +package player + +import ( + "math" + "time" + + "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/item/enchantment" + "github.com/df-mc/dragonfly/server/world" + "github.com/df-mc/dragonfly/server/world/sound" + "github.com/go-gl/mathgl/mgl64" +) + +const ( + shieldBlockDelay = time.Second / 4 + shieldDisableCooldown = 5 * time.Second + shieldDamageThreshold = 3 + shieldItemName = "minecraft:shield" + + shieldAttackerKnockBackForce = 0.4 + shieldAttackerKnockBackHeight = 0.4 +) + +type shieldHand int + +const ( + shieldHandMain shieldHand = iota + shieldHandOff +) + +type shieldDisabler interface { + HeldItems() (item.Stack, item.Stack) +} + +type shieldKnockBacker interface { + KnockBack(src mgl64.Vec3, force, height float64) +} + +// Blocking returns true if the player is currently blocking with a shield. +func (p *Player) Blocking() bool { + return p.shieldBlockingAt(time.Now()) +} + +func (p *Player) shieldBlockingAt(now time.Time) bool { + if p.shieldBlockingSince.IsZero() || !p.canBlockWithShieldAt(now) { + return false + } + return !now.Before(p.shieldBlockingSince.Add(shieldBlockDelay)) +} + +func (p *Player) updateShieldBlockingState(now time.Time) bool { + wasPrepared, wasBlocking := !p.shieldBlockingSince.IsZero(), p.shieldBlockingCached + if !p.canBlockWithShieldAt(now) { + p.shieldBlockingSince = time.Time{} + p.shieldBlockingCached = false + return wasPrepared || wasBlocking + } + if !wasPrepared { + p.shieldBlockingSince = now + } + p.shieldBlockingCached = !now.Before(p.shieldBlockingSince.Add(shieldBlockDelay)) + return !wasPrepared || wasBlocking != p.shieldBlockingCached +} + +func (p *Player) resetShieldBlocking() bool { + wasPrepared, wasBlocking := !p.shieldBlockingSince.IsZero(), p.shieldBlockingCached + p.shieldBlockingSince = time.Time{} + p.shieldBlockingCached = false + return wasPrepared || wasBlocking +} + +func (p *Player) canBlockWithShieldAt(now time.Time) bool { + if (!p.Sneaking() && !p.shieldBlockingInput) || p.hasCooldownAt(item.Shield{}, now) { + return false + } + _, _, ok := p.heldShield() + return ok +} + +func (p *Player) heldShield() (item.Stack, shieldHand, bool) { + mainHand, offHand := p.HeldItems() + if _, ok := mainHand.Item().(item.Shield); ok { + return mainHand, shieldHandMain, true + } + if _, ok := offHand.Item().(item.Shield); ok { + return offHand, shieldHandOff, true + } + return item.Stack{}, 0, false +} + +func (p *Player) setHeldShield(hand shieldHand, shield item.Stack) { + if hand == shieldHandMain { + _ = p.inv.SetItem(int(*p.heldSlot), shield) + return + } + _ = p.offHand.SetItem(0, shield) +} + +func (p *Player) shieldBlocksDamageAt(src world.DamageSource, now time.Time) bool { + if !p.shieldBlockingAt(now) { + return false + } + source, ok := shieldDamageSourcePosition(src) + if !ok { + return false + } + return p.facingShieldDamageSource(source) +} + +func (p *Player) facingShieldDamageSource(source mgl64.Vec3) bool { + direction := source.Sub(p.Position()) + direction[1] = 0 + if direction.Len() == 0 { + return false + } + look := cube.Rotation{p.Rotation().Yaw(), 0}.Vec3() + return direction.Normalize().Dot(look) > 0 +} + +func shieldDamageSourcePosition(src world.DamageSource) (mgl64.Vec3, bool) { + switch s := src.(type) { + case entity.AttackDamageSource: + if s.Attacker == nil { + return mgl64.Vec3{}, false + } + return s.Attacker.Position(), true + case entity.ProjectileDamageSource: + if s.Projectile != nil { + return s.Projectile.Position(), true + } + if s.Owner != nil { + return s.Owner.Position(), true + } + case entity.ExplosionDamageSource: + if !s.HasOrigin || !s.BlockableByShield { + return mgl64.Vec3{}, false + } + return s.Origin, true + case enchantment.ThornsDamageSource: + if s.Owner == nil { + return mgl64.Vec3{}, false + } + return s.Owner.Position(), true + } + return mgl64.Vec3{}, false +} + +func shieldDisableCooldownFrom(src world.DamageSource) (time.Duration, bool) { + attack, ok := src.(entity.AttackDamageSource) + if !ok { + return 0, false + } + attacker, ok := attack.Attacker.(shieldDisabler) + if !ok { + return 0, false + } + mainHand, _ := attacker.HeldItems() + if _, ok := mainHand.Item().(item.Axe); !ok { + return 0, false + } + return shieldDisableCooldown, true +} + +func shieldDurabilityDamage(dmg float64) int { + if dmg < shieldDamageThreshold { + return 0 + } + return int(math.Floor(dmg)) + 1 +} + +func shouldAttemptShieldBlock(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { + if damageLeft < 0 { + return false + } + if damageLeft > 0 { + return true + } + if damageBeforeHandler > 0 { + return false + } + if rawDamage > 0 { + return true + } + _, ok := src.(entity.ProjectileDamageSource) + return ok && rawDamage == 0 +} + +func (p *Player) useItemStartsShieldBlocking(mainHand item.Stack) bool { + if _, ok := mainHand.Item().(item.Shield); ok { + return true + } + switch mainHand.Item().(type) { + case item.Releasable, item.Chargeable, item.Usable, item.Consumable: + return false + } + _, offHand := p.HeldItems() + _, ok := offHand.Item().(item.Shield) + return ok +} + +// StartShieldBlockingInput starts shield blocking from an item-use input if the held items allow it. +func (p *Player) StartShieldBlockingInput() bool { + mainHand, _ := p.HeldItems() + if !p.useItemStartsShieldBlocking(mainHand) { + return false + } + p.SetShieldBlockingInput(true) + return true +} + +func (p *Player) knockBackShieldAttacker(src world.DamageSource) bool { + attack, ok := src.(entity.AttackDamageSource) + if !ok { + return false + } + attacker, ok := attack.Attacker.(shieldKnockBacker) + if !ok { + return false + } + attacker.KnockBack(p.Position(), shieldAttackerKnockBackForce, shieldAttackerKnockBackHeight) + return true +} + +func (p *Player) blockDamageWithShield(dmg float64, src world.DamageSource) bool { + now := time.Now() + if s, ok := src.(entity.ExplosionDamageSource); ok && s.Source != nil && s.Source.H() != nil && p.H() != nil && s.Source.H().UUID() == p.H().UUID() { + return false + } + if !p.shieldBlocksDamageAt(src, now) { + return false + } + shield, hand, ok := p.heldShield() + if !ok { + return false + } + if damage := shieldDurabilityDamage(dmg); damage > 0 { + p.setHeldShield(hand, p.damageItem(shield, damage)) + } + if s, ok := src.(entity.ProjectileDamageSource); ok && s.ShieldBlockHandler != nil { + s.ShieldBlockHandler.HandleShieldBlock() + } + if p.tx != nil { + p.tx.PlaySound(p.Position(), sound.ShieldBlock{}) + } + p.knockBackShieldAttacker(src) + if cooldown, ok := shieldDisableCooldownFrom(src); ok { + p.setCooldown(item.Shield{}, cooldown, false) + p.resetShieldBlocking() + } else { + p.updateShieldBlockingState(now) + } + if p.tx != nil { + p.updateState() + } + return true +} diff --git a/server/player/shield_test.go b/server/player/shield_test.go new file mode 100644 index 000000000..00e9d90ff --- /dev/null +++ b/server/player/shield_test.go @@ -0,0 +1,371 @@ +package player + +import ( + "testing" + "time" + + "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/item/enchantment" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +type shieldTestEntity struct { + pos mgl64.Vec3 +} + +func (e shieldTestEntity) Close() error { return nil } +func (e shieldTestEntity) H() *world.EntityHandle { return nil } +func (e shieldTestEntity) Position() mgl64.Vec3 { return e.pos } +func (e shieldTestEntity) Rotation() cube.Rotation { + return cube.Rotation{} +} +func (e shieldTestEntity) HeldItems() (item.Stack, item.Stack) { + return item.Stack{}, item.Stack{} +} + +type shieldAxeAttacker struct { + shieldTestEntity + mainHand item.Stack +} + +func (a shieldAxeAttacker) HeldItems() (item.Stack, item.Stack) { + return a.mainHand, item.Stack{} +} + +type shieldKnockBackAttacker struct { + shieldTestEntity + src mgl64.Vec3 + force, height float64 +} + +func (a *shieldKnockBackAttacker) KnockBack(src mgl64.Vec3, force, height float64) { + a.src, a.force, a.height = src, force, height +} + +func newShieldTestPlayer(rot cube.Rotation, mainHand, offHand item.Stack) *Player { + heldSlot := uint32(0) + inv := inventory.New(36, nil) + _ = inv.SetItem(0, mainHand) + + off := inventory.New(1, nil) + _ = off.SetItem(0, offHand) + + return &Player{ + data: &world.EntityData{Rot: rot}, + playerData: &playerData{ + gameMode: world.GameModeSurvival, + h: NopHandler{}, + inv: inv, + offHand: off, + heldSlot: &heldSlot, + sneaking: true, + cooldowns: map[string]time.Time{}, + health: entity.NewHealthManager(20, 20), + effects: entity.NewEffectManager(), + armour: inventory.NewArmour(nil), + hunger: newHungerManager(), + }, + } +} + +func TestShieldBlockingRequiresSneakingReadyShieldAndStartupDelay(t *testing.T) { + now := time.Unix(10, 0) + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + + if changed := p.updateShieldBlockingState(now); !changed { + t.Fatal("expected shield blocking state to start when sneaking with a shield") + } + if p.shieldBlockingAt(now.Add(shieldBlockDelay - time.Nanosecond)) { + t.Fatal("expected shield to wait for the vanilla startup delay before blocking") + } + if !p.shieldBlockingAt(now.Add(shieldBlockDelay)) { + t.Fatal("expected shield to block after the vanilla startup delay") + } + + p.sneaking = false + if p.shieldBlockingAt(now.Add(shieldBlockDelay)) { + t.Fatal("expected shield not to block while not sneaking") + } + + p.shieldBlockingInput = true + if !p.shieldBlockingAt(now.Add(shieldBlockDelay)) { + t.Fatal("expected shield to block while the shield input is held") + } + p.shieldBlockingInput = false + + p.sneaking = true + p.SetCooldown(item.Shield{}, shieldDisableCooldown) + if p.shieldBlockingAt(now.Add(shieldBlockDelay)) { + t.Fatal("expected shield not to block while its item cooldown is active") + } +} + +func TestUseItemStartsShieldBlockingInput(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + + p.UseItem() + if !p.shieldBlockingInput { + t.Fatal("expected using a held shield to start shield blocking input") + } + if !p.shieldBlockingAt(p.shieldBlockingSince.Add(shieldBlockDelay)) { + t.Fatal("expected shield to block after use input and startup delay") + } +} + +func TestReleaseItemStopsShieldBlockingInput(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.shieldBlockingInput = true + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + + p.ReleaseItem() + if p.shieldBlockingInput { + t.Fatal("expected releasing item to stop shield blocking input") + } + if p.Blocking() { + t.Fatal("expected shield not to block after use input is released") + } +} + +func TestStartShieldBlockingInputHonoursUsePriority(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.Bow{}, 1), item.NewStack(item.Shield{}, 1)) + p.sneaking = false + + if p.StartShieldBlockingInput() { + t.Fatal("expected main-hand bow use to take priority over offhand shield blocking") + } + if p.shieldBlockingInput { + t.Fatal("expected shield input to stay inactive while main-hand bow use has priority") + } +} + +func TestStopSneakingPreservesHeldShieldInput(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingInput = true + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + + w := world.New() + defer func() { + _ = w.Close() + }() + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + p.StopSneaking() + }) + if !p.shieldBlockingInput { + t.Fatal("expected stop sneaking not to clear held raw shield input") + } + if p.shieldBlockingSince.IsZero() { + t.Fatal("expected shield warmup to be preserved while raw shield input is still held") + } +} + +func TestShieldBlocksOnlyFrontBlockableDamage(t *testing.T) { + now := time.Unix(10, 0) + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = now.Add(-shieldBlockDelay) + + front := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} + behind := shieldTestEntity{pos: mgl64.Vec3{0, 0, -4}} + + if !p.shieldBlocksDamageAt(entity.AttackDamageSource{Attacker: front}, now) { + t.Fatal("expected shield to block a front melee attack") + } + if p.shieldBlocksDamageAt(entity.AttackDamageSource{Attacker: behind}, now) { + t.Fatal("expected shield not to block an attack from behind") + } + if p.shieldBlocksDamageAt(entity.FallDamageSource{}, now) { + t.Fatal("expected shield not to block fall damage") + } + if !p.shieldBlocksDamageAt(enchantment.ThornsDamageSource{Owner: front}, now) { + t.Fatal("expected shield to block front thorns damage") + } +} + +func TestShieldBlocksProjectileAndExplosionFromFront(t *testing.T) { + now := time.Unix(10, 0) + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = now.Add(-shieldBlockDelay) + + projectile := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} + if !p.shieldBlocksDamageAt(entity.ProjectileDamageSource{Projectile: projectile}, now) { + t.Fatal("expected shield to block a front projectile") + } + if !p.shieldBlocksDamageAt(entity.ExplosionDamageSource{Origin: mgl64.Vec3{0, 0, 4}, HasOrigin: true, BlockableByShield: true}, now) { + t.Fatal("expected shield to block a front explosion") + } + if p.shieldBlocksDamageAt(entity.ExplosionDamageSource{}, now) { + t.Fatal("expected shield not to block an explosion with no origin") + } + if p.shieldBlocksDamageAt(entity.ExplosionDamageSource{Origin: mgl64.Vec3{0, 0, 4}, HasOrigin: true}, now) { + t.Fatal("expected shield not to block an explosion marked unblockable") + } +} + +func TestShieldDoesNotBlockSelfSourcedExplosion(t *testing.T) { + now := time.Unix(10, 0) + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.handle = entity.NewText("", mgl64.Vec3{}) + p.shieldBlockingSince = now.Add(-shieldBlockDelay) + + src := entity.ExplosionDamageSource{ + Origin: mgl64.Vec3{0, 0, 4}, + HasOrigin: true, + BlockableByShield: true, + Source: p, + } + if p.blockDamageWithShield(4, src) { + t.Fatal("expected shield not to block a self-sourced explosion") + } +} + +func TestShieldDoesNotBlockCancelledDamage(t *testing.T) { + now := time.Unix(10, 0) + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = now.Add(-shieldBlockDelay) + p.h = cancellingHurtHandler{} + + front := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} + if _, vulnerable := p.Hurt(4, entity.AttackDamageSource{Attacker: front}); vulnerable { + t.Fatal("expected cancelled damage not to be vulnerable") + } + _, offHand := p.HeldItems() + if offHand.Durability() != offHand.MaxDurability() { + t.Fatal("expected shield not to lose durability for damage cancelled by the hurt handler") + } +} + +func TestShieldBlocksZeroDamageProjectile(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + projectile := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} + handler := &shieldBlockTestHandler{} + + if dmg, vulnerable := p.Hurt(0, entity.ProjectileDamageSource{Projectile: projectile, ShieldBlockHandler: handler}); dmg != 0 || vulnerable { + t.Fatalf("expected shield-blocked zero damage projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) + } + if !handler.blocked { + t.Fatal("expected zero damage projectile shield block callback to run") + } +} + +type shieldBlockTestHandler struct { + blocked bool +} + +func (h *shieldBlockTestHandler) HandleShieldBlock() { + h.blocked = true +} + +func TestShieldDurabilityUsesDamageBeforeArmourReduction(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + p.armour.SetChestplate(item.NewStack(item.Chestplate{Tier: item.ArmourTierDiamond{}}, 1)) + attacker := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} + + p.Hurt(4, entity.AttackDamageSource{Attacker: attacker}) + + _, offHand := p.HeldItems() + if got, want := offHand.Durability(), item.NewStack(item.Shield{}, 1).MaxDurability()-5; got != want { + t.Fatalf("expected shield durability %v after blocking 4 raw damage, got %v", want, got) + } +} + +func TestExplosionKnockBackNotSuppressedByNestedShieldBlock(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.data.Pos = mgl64.Vec3{0, 0, 1} + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + p.h = &nestedShieldBlockHandler{ + player: p, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}}}, + } + + w := world.New() + defer func() { + _ = w.Close() + }() + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + p.Explode(mgl64.Vec3{}, 0.2, block.ExplosionConfig{Size: 1, UnblockableByShield: true}) + }) + if p.Velocity().Len() == 0 { + t.Fatal("expected unblockable explosion to knock back even if a nested hurt was shield-blocked") + } +} + +type cancellingHurtHandler struct { + NopHandler +} + +func (cancellingHurtHandler) HandleHurt(ctx *Context, _ *float64, _ bool, _ *time.Duration, _ world.DamageSource) { + ctx.Cancel() +} + +type nestedShieldBlockHandler struct { + NopHandler + player *Player + src world.DamageSource + done bool +} + +func (h *nestedShieldBlockHandler) HandleHurt(_ *Context, _ *float64, _ bool, _ *time.Duration, _ world.DamageSource) { + if h.done { + return + } + h.done = true + h.player.Hurt(4, h.src) +} + +func TestShieldDisableCooldownFromAxeAttack(t *testing.T) { + attacker := shieldAxeAttacker{mainHand: item.NewStack(item.Axe{Tier: item.ToolTierWood}, 1)} + cooldown, ok := shieldDisableCooldownFrom(entity.AttackDamageSource{Attacker: attacker}) + if !ok { + t.Fatal("expected an axe attack to disable shields") + } + if cooldown != shieldDisableCooldown { + t.Fatalf("expected shield disable cooldown %v, got %v", shieldDisableCooldown, cooldown) + } + + if _, ok := shieldDisableCooldownFrom(entity.AttackDamageSource{Attacker: shieldTestEntity{}}); ok { + t.Fatal("expected a non-axe attack not to disable shields") + } +} + +func TestShieldKnocksBackMeleeAttacker(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + attacker := &shieldKnockBackAttacker{shieldTestEntity: shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}}} + + if !p.knockBackShieldAttacker(entity.AttackDamageSource{Attacker: attacker}) { + t.Fatal("expected melee attacker to be knocked back") + } + if attacker.src != p.Position() { + t.Fatalf("expected attacker to be knocked away from player position %v, got %v", p.Position(), attacker.src) + } + if attacker.force != shieldAttackerKnockBackForce || attacker.height != shieldAttackerKnockBackHeight { + t.Fatalf("expected knockback %v/%v, got %v/%v", shieldAttackerKnockBackForce, shieldAttackerKnockBackHeight, attacker.force, attacker.height) + } + if p.knockBackShieldAttacker(entity.ProjectileDamageSource{Projectile: attacker}) { + t.Fatal("expected projectile source not to knock back the attacker") + } +} + +func TestShieldDurabilityDamage(t *testing.T) { + for _, test := range []struct { + damage float64 + want int + }{ + {damage: 2.9, want: 0}, + {damage: 3, want: 4}, + {damage: 7.2, want: 8}, + } { + if got := shieldDurabilityDamage(test.damage); got != test.want { + t.Fatalf("shieldDurabilityDamage(%v) = %v, want %v", test.damage, got, test.want) + } + } +} diff --git a/server/session/entity_metadata.go b/server/session/entity_metadata.go index 427266aac..9a3869513 100644 --- a/server/session/entity_metadata.go +++ b/server/session/entity_metadata.go @@ -81,6 +81,9 @@ func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) { if u, ok := e.(using); ok && u.UsingItem() { m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagUsingItem) } + if b, ok := e.(blocker); ok && b.Blocking() { + m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagBlocking) + } if c, ok := e.(arrow); ok && c.Critical() { m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagCritical) } @@ -218,6 +221,10 @@ type breather interface { MaxAirSupply() time.Duration } +type blocker interface { + Blocking() bool +} + type immobile interface { Immobile() bool } diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index f75fcba2f..0673f636a 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -98,6 +98,7 @@ func (h PlayerAuthInputHandler) handleActions(pk *packet.PlayerAuthInput, s *Ses // handleInputFlags handles the toggleable input flags set in a PlayerAuthInput packet. func (h PlayerAuthInputHandler) handleInputFlags(flags protocol.Bitset, s *Session, c Controllable) { + wasSneaking := c.Sneaking() if flags.Load(packet.InputFlagStartSprinting) { c.StartSprinting() } @@ -131,6 +132,14 @@ func (h PlayerAuthInputHandler) handleInputFlags(flags protocol.Bitset, s *Sessi if flags.Load(packet.InputFlagStopCrawling) { c.StopCrawling() } + if setter, ok := c.(shieldBlockingInputSetter); ok { + if down, ok := shieldBlockingInput(flags, wasSneaking, c.Sneaking()); ok { + setter.SetShieldBlockingInput(down) + } + } + if starter, ok := c.(shieldBlockingInputStarter); ok && flags.Load(packet.InputFlagStartUsingItem) { + starter.StartShieldBlockingInput() + } if flags.Load(packet.InputFlagMissedSwing) { s.swingingArm.Store(true) defer s.swingingArm.Store(false) @@ -149,6 +158,27 @@ func (h PlayerAuthInputHandler) handleInputFlags(flags protocol.Bitset, s *Sessi } } +type shieldBlockingInputSetter interface { + SetShieldBlockingInput(down bool) +} + +type shieldBlockingInputStarter interface { + StartShieldBlockingInput() bool +} + +func shieldBlockingInput(flags protocol.Bitset, wasSneaking, sneaking bool) (bool, bool) { + if flags.Load(packet.InputFlagSneaking) || flags.Load(packet.InputFlagSneakDown) || flags.Load(packet.InputFlagSneakCurrentRaw) { + if flags.Load(packet.InputFlagStartSneaking) && !wasSneaking && !sneaking { + return false, false + } + return true, true + } + if flags.Load(packet.InputFlagStopSneaking) || flags.Load(packet.InputFlagSneakReleasedRaw) { + return false, true + } + return false, false +} + // handleUseItemData handles the protocol.UseItemTransactionData found in a packet.PlayerAuthInput. func (h PlayerAuthInputHandler) handleUseItemData(data protocol.UseItemTransactionData, s *Session, c Controllable) error { s.swingingArm.Store(true) diff --git a/server/session/handler_player_auth_input_test.go b/server/session/handler_player_auth_input_test.go new file mode 100644 index 000000000..c071a322d --- /dev/null +++ b/server/session/handler_player_auth_input_test.go @@ -0,0 +1,54 @@ +package session + +import ( + "testing" + + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +func TestShieldBlockingInputPrefersHeldRawInputOverStopSneaking(t *testing.T) { + flags := protocol.NewBitset(packet.PlayerAuthInputBitsetSize) + flags.Set(packet.InputFlagSneakCurrentRaw) + flags.Set(packet.InputFlagStopSneaking) + + down, ok := shieldBlockingInput(flags, true, false) + if !ok { + t.Fatal("expected shield input to be updated") + } + if !down { + t.Fatal("expected held raw sneak input to keep shield input active") + } +} + +func TestShieldBlockingInputStopsOnSneakRelease(t *testing.T) { + flags := protocol.NewBitset(packet.PlayerAuthInputBitsetSize) + flags.Set(packet.InputFlagSneakReleasedRaw) + + down, ok := shieldBlockingInput(flags, true, false) + if !ok { + t.Fatal("expected shield input to be updated") + } + if down { + t.Fatal("expected released raw sneak input to stop shield input") + } +} + +func TestShieldBlockingInputIgnoresUseItem(t *testing.T) { + flags := protocol.NewBitset(packet.PlayerAuthInputBitsetSize) + flags.Set(packet.InputFlagStartUsingItem) + + if down, ok := shieldBlockingInput(flags, false, false); ok || down { + t.Fatal("expected generic shield input helper not to start from item use") + } +} + +func TestShieldBlockingInputIgnoresCancelledStartSneaking(t *testing.T) { + flags := protocol.NewBitset(packet.PlayerAuthInputBitsetSize) + flags.Set(packet.InputFlagStartSneaking) + flags.Set(packet.InputFlagSneakCurrentRaw) + + if down, ok := shieldBlockingInput(flags, false, false); ok || down { + t.Fatal("expected cancelled start sneaking not to start shield input") + } +} diff --git a/server/session/world.go b/server/session/world.go index 3e715f687..4aa4ad1ea 100644 --- a/server/session/world.go +++ b/server/session/world.go @@ -719,6 +719,8 @@ func (s *Session) playSound(pos mgl64.Vec3, t world.Sound, disableRelative bool) pk.SoundType, pk.ExtraData = packet.SoundEventHit, int32(s.br.BlockRuntimeID(so.Block)) case sound.ItemBreak: pk.SoundType = packet.SoundEventBreak + case sound.ShieldBlock: + pk.SoundType = packet.SoundEventShieldBlock case sound.ItemUseOn: pk.SoundType, pk.ExtraData = packet.SoundEventItemUseOn, int32(s.br.BlockRuntimeID(so.Block)) case sound.Fizz: diff --git a/server/world/entity.go b/server/world/entity.go index dcc415ce9..28c27415e 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -360,11 +360,14 @@ type EntityRegistry struct { // EntityRegistryConfig holds functions used by the block and item packages to // create entities as a result of their behaviour. ALL functions of // EntityRegistryConfig must be filled out for the behaviour of these blocks and -// items not to fail. +// items not to fail, except those explicitly documented as optional. 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 + Item func(opts EntitySpawnOpts, it any) *EntityHandle + FallingBlock func(opts EntitySpawnOpts, bl Block) *EntityHandle + TNT func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle + // TNTWithSource optionally creates a TNT entity with the entity that caused it to ignite. If nil, or if the + // source is nil, TNT is used instead. + TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source Entity) *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 Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle diff --git a/server/world/sound/item.go b/server/world/sound/item.go index 69ddda6a2..4f13ad3bd 100644 --- a/server/world/sound/item.go +++ b/server/world/sound/item.go @@ -6,6 +6,9 @@ import "github.com/df-mc/dragonfly/server/world" // durability and breaks. type ItemBreak struct{ sound } +// ShieldBlock is a sound played when a shield blocks damage. +type ShieldBlock struct{ sound } + // ItemThrow is a sound played when a player throws an item, such as a snowball. type ItemThrow struct{ sound } From 7db443fd358231d991be8b0560ab8ac1dc6c46f2 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 20:58:58 +0000 Subject: [PATCH 02/16] Address shield review feedback --- server/block/fire.go | 4 +- server/block/tnt.go | 18 ++-- server/block/tnt_test.go | 41 ++++++++- server/entity/damage.go | 23 +++-- server/entity/projectile.go | 14 +-- server/entity/projectile_test.go | 4 +- server/entity/tnt.go | 25 ++++-- server/entity/tnt_test.go | 51 +++++++++++ server/player/player.go | 14 +-- server/player/shield.go | 38 ++++++--- server/player/shield_test.go | 113 +++++++++++++++++++++++-- server/session/entity_metadata.go | 8 +- server/session/entity_metadata_test.go | 37 ++++++++ server/world/entity.go | 6 +- 14 files changed, 327 insertions(+), 69 deletions(-) create mode 100644 server/entity/tnt_test.go create mode 100644 server/session/entity_metadata_test.go diff --git a/server/block/fire.go b/server/block/fire.go index 59a6052be..f2fa52c5d 100644 --- a/server/block/fire.go +++ b/server/block/fire.go @@ -56,8 +56,8 @@ func infinitelyBurning(pos cube.Pos, tx *world.Tx) bool { // burn attempts to burn a block. func (f Fire) burn(from, to cube.Pos, tx *world.Tx, r *rand.Rand, chanceBound int) { if flammable, ok := tx.Block(to).(Flammable); ok && r.IntN(chanceBound) < flammable.FlammabilityInfo().Flammability { - if t, ok := flammable.(TNT); ok { - t.Ignite(to, tx, nil) + if _, ok := flammable.(TNT); ok { + spawnTnt(to, tx, time.Second*4, nil, true) return } if r.IntN(f.Age+10) < 5 && !rainingAround(to, tx) { diff --git a/server/block/tnt.go b/server/block/tnt.go index ba13c8077..b31b7040b 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -19,7 +19,7 @@ type TNT struct { // ProjectileHit ... func (t TNT) ProjectileHit(pos cube.Pos, tx *world.Tx, e world.Entity, _ cube.Face) { if f, ok := e.(flammableEntity); ok && f.OnFireDuration() > 0 { - spawnTnt(pos, tx, time.Second*4, tntIgnitionSourceHandle(e)) + spawnTnt(pos, tx, time.Second*4, tntIgnitionSourceHandle(e), true) } } @@ -36,13 +36,13 @@ func (t TNT) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx // Ignite ... func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, source world.Entity) bool { - spawnTnt(pos, tx, time.Second*4, entityHandle(source)) + spawnTnt(pos, tx, time.Second*4, entityHandle(source), source != nil) return true } // Explode ... func (t TNT) Explode(_ mgl64.Vec3, pos cube.Pos, tx *world.Tx, c ExplosionConfig) { - spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2))), tntExplosionSourceHandle(c)) + spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2))), tntExplosionSourceHandle(c), !c.UnblockableByShield) } // BreakInfo ... @@ -93,16 +93,18 @@ func entityHandle(e world.Entity) *world.EntityHandle { return e.H() } -func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration, source *world.EntityHandle) { +func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration, source *world.EntityHandle, blockableByShield bool) { tx.PlaySound(pos.Vec3Centre(), sound.TNT{}) tx.SetBlock(pos, nil, nil) opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} conf := tx.World().EntityRegistry().Config() - if source != nil && conf.TNTWithSource != nil { - if e, ok := source.Entity(tx); ok { - tx.AddEntity(conf.TNTWithSource(opts, fuse, e)) - return + if (source != nil || blockableByShield) && conf.TNTWithSource != nil { + var e world.Entity + if source != nil { + e, _ = source.Entity(tx) } + tx.AddEntity(conf.TNTWithSource(opts, fuse, e, blockableByShield)) + return } tx.AddEntity(conf.TNT(opts, fuse)) } diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go index 83352f73a..76704613f 100644 --- a/server/block/tnt_test.go +++ b/server/block/tnt_test.go @@ -2,6 +2,7 @@ package block import ( "testing" + "time" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" @@ -11,11 +12,12 @@ import ( type tntSourceEntity struct { h *world.EntityHandle owner *world.EntityHandle + pos mgl64.Vec3 } func (e tntSourceEntity) Close() error { return nil } func (e tntSourceEntity) H() *world.EntityHandle { return e.h } -func (e tntSourceEntity) Position() mgl64.Vec3 { return mgl64.Vec3{} } +func (e tntSourceEntity) Position() mgl64.Vec3 { return e.pos } func (e tntSourceEntity) Rotation() cube.Rotation { return cube.Rotation{} } @@ -23,14 +25,19 @@ func (e tntSourceEntity) ProjectileOwner() *world.EntityHandle { return e.owner type tntTestEntityType struct{} -func (tntTestEntityType) Open(*world.Tx, *world.EntityHandle, *world.EntityData) world.Entity { - return nil +func (tntTestEntityType) Open(_ *world.Tx, h *world.EntityHandle, data *world.EntityData) world.Entity { + return tntSourceEntity{h: h}.withPosition(data.Pos) } func (tntTestEntityType) EncodeEntity() string { return "dragonfly:test_entity" } func (tntTestEntityType) BBox(world.Entity) cube.BBox { return cube.Box(0, 0, 0, 0, 0, 0) } func (tntTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} func (tntTestEntityType) EncodeNBT(*world.EntityData) map[string]any { return nil } func (tntTestEntityType) Apply(data *world.EntityData) {} + +func (e tntSourceEntity) withPosition(pos mgl64.Vec3) tntSourceEntity { + e.pos = pos + return e +} func newTNTTestHandle() *world.EntityHandle { return world.EntitySpawnOpts{}.New(tntTestEntityType{}, tntTestEntityType{}) } @@ -59,3 +66,31 @@ func TestTNTExplosionSourceUsesExplosionConfigSource(t *testing.T) { t.Fatalf("expected chained TNT source to use explosion config source handle %v, got %v", source.H(), got) } } + +func TestTNTSpawnCanCreateShieldBlockableTNTWithoutSource(t *testing.T) { + var blockable bool + var source world.Entity + registry := world.EntityRegistryConfig{ + TNT: func(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src world.Entity, blockableByShield bool) *world.EntityHandle { + source, blockable = src, blockableByShield + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + }.New([]world.EntityType{tntTestEntityType{}}) + w := world.Config{Entities: registry}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + spawnTnt(cube.Pos{}, tx, time.Second, nil, true) + }) + if source != nil { + t.Fatalf("expected no TNT source entity, got %T", source) + } + if !blockable { + t.Fatal("expected source-less environmental TNT to be shield blockable") + } +} diff --git a/server/entity/damage.go b/server/entity/damage.go index 318d4eabb..92a016588 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -7,9 +7,22 @@ import ( "github.com/go-gl/mathgl/mgl64" ) -// ShieldBlockHandler handles a projectile damage source being blocked by a shield. -type ShieldBlockHandler interface { - HandleShieldBlock() +// ProjectileShieldBlockMarker is attached to a projectile damage source so that +// the projectile can tell if its damage was blocked by a shield. +type ProjectileShieldBlockMarker struct { + shieldBlocked bool +} + +// MarkShieldBlocked marks the projectile damage as blocked by a shield. +func (m *ProjectileShieldBlockMarker) MarkShieldBlocked() { + if m != nil { + m.shieldBlocked = true + } +} + +// ShieldBlocked returns true if the projectile damage was blocked by a shield. +func (m *ProjectileShieldBlockMarker) ShieldBlocked() bool { + return m != nil && m.shieldBlocked } type ( @@ -48,8 +61,8 @@ type ( // Projectile and Owner are the world.Entity that dealt the damage and // the one that fired the projectile respectively. Projectile, Owner world.Entity - // ShieldBlockHandler is called if the projectile damage is blocked by a shield. - ShieldBlockHandler ShieldBlockHandler + // ShieldBlockMarker is marked if the projectile damage is blocked by a shield. + ShieldBlockMarker *ProjectileShieldBlockMarker } // ExplosionDamageSource is used for damage caused by an explosion. diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 33fedf9ab..63e3f7110 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -267,13 +267,13 @@ func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) bool if lt.conf.Owner != nil { owner, _ = lt.conf.Owner.Entity(e.tx) } - blockHandler := &projectileShieldBlockHandler{} - src := ProjectileDamageSource{Projectile: e, Owner: owner, ShieldBlockHandler: blockHandler} + blockMarker := &ProjectileShieldBlockMarker{} + src := ProjectileDamageSource{Projectile: e, Owner: owner, ShieldBlockMarker: blockMarker} dmg := math.Ceil(lt.conf.Damage * vel.Len()) if lt.conf.Critical { dmg += rand.Float64() * dmg / 2 } - if _, vulnerable := l.Hurt(dmg, src); blockHandler.blocked { + if _, vulnerable := l.Hurt(dmg, src); blockMarker.ShieldBlocked() { lt.deflect(e, vel) return true } else if vulnerable { @@ -302,14 +302,6 @@ func (lt *ProjectileBehaviour) emitHitEffects(tx *world.Tx, result trace.Result) } } -type projectileShieldBlockHandler struct { - blocked bool -} - -func (h *projectileShieldBlockHandler) HandleShieldBlock() { - h.blocked = true -} - func (lt *ProjectileBehaviour) deflect(e *Ent, vel mgl64.Vec3) { if vel.Len() == 0 { return diff --git a/server/entity/projectile_test.go b/server/entity/projectile_test.go index 5da260b8c..d12a0856c 100644 --- a/server/entity/projectile_test.go +++ b/server/entity/projectile_test.go @@ -36,8 +36,8 @@ func (t *projectileShieldTarget) Speed() float64 { retur func (t *projectileShieldTarget) SetSpeed(float64) {} func (t *projectileShieldTarget) Hurt(_ float64, src world.DamageSource) (float64, bool) { - if s, ok := src.(ProjectileDamageSource); ok && t.blocked && s.ShieldBlockHandler != nil { - s.ShieldBlockHandler.HandleShieldBlock() + if s, ok := src.(ProjectileDamageSource); ok && t.blocked && s.ShieldBlockMarker != nil { + s.ShieldBlockMarker.MarkShieldBlocked() } return 0, t.vulnerable } diff --git a/server/entity/tnt.go b/server/entity/tnt.go index 9a71ec5d7..19b6cea16 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -13,19 +13,19 @@ import ( // NewTNT creates a new primed TNT entity. func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { - return newTNTWithSourceHandle(opts, fuse, nil) + return newTNTWithSourceHandle(opts, fuse, nil, false) } // NewTNTWithSource creates a new primed TNT entity with the entity that caused it to ignite. -func NewTNTWithSource(opts world.EntitySpawnOpts, fuse time.Duration, source world.Entity) *world.EntityHandle { - return newTNTWithSourceHandle(opts, fuse, entityHandle(source)) +func NewTNTWithSource(opts world.EntitySpawnOpts, fuse time.Duration, source world.Entity, blockableByShield bool) *world.EntityHandle { + return newTNTWithSourceHandle(opts, fuse, entityHandle(source), blockableByShield) } -func newTNTWithSourceHandle(opts world.EntitySpawnOpts, fuse time.Duration, source *world.EntityHandle) *world.EntityHandle { +func newTNTWithSourceHandle(opts world.EntitySpawnOpts, fuse time.Duration, source *world.EntityHandle, blockableByShield bool) *world.EntityHandle { conf := tntConf conf.ExistenceDuration = fuse conf.Expire = func(e *Ent, tx *world.Tx) { - explodeTNT(e, tx, source) + explodeTNT(e, tx, source, blockableByShield) } if opts.Velocity.Len() == 0 { angle := rand.Float64() * math.Pi * 2 @@ -45,14 +45,21 @@ var tntConf = PassiveBehaviourConfig{ Gravity: 0.04, Drag: 0.02, Expire: func(e *Ent, tx *world.Tx) { - explodeTNT(e, tx, nil) + explodeTNT(e, tx, nil, false) }, } // explodeTNT creates an explosion at the position of e. -func explodeTNT(e *Ent, tx *world.Tx, source *world.EntityHandle) { - sourceEntity, ok := source.Entity(tx) - block.ExplosionConfig{ItemDropChance: 1, UnblockableByShield: !ok, Source: sourceEntity}.Explode(tx, e.Position()) +func explodeTNT(e *Ent, tx *world.Tx, source *world.EntityHandle, blockableByShield bool) { + tntExplosionConfig(tx, source, blockableByShield).Explode(tx, e.Position()) +} + +func tntExplosionConfig(tx *world.Tx, source *world.EntityHandle, blockableByShield bool) block.ExplosionConfig { + var sourceEntity world.Entity + if source != nil { + sourceEntity, _ = source.Entity(tx) + } + return block.ExplosionConfig{ItemDropChance: 1, UnblockableByShield: !blockableByShield, Source: sourceEntity} } // TNTType is a world.EntityType implementation for TNT. diff --git a/server/entity/tnt_test.go b/server/entity/tnt_test.go new file mode 100644 index 000000000..be9d89e2b --- /dev/null +++ b/server/entity/tnt_test.go @@ -0,0 +1,51 @@ +package entity + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +type tntTestEntityType struct{} + +func (tntTestEntityType) Open(*world.Tx, *world.EntityHandle, *world.EntityData) world.Entity { + return nil +} +func (tntTestEntityType) EncodeEntity() string { return "dragonfly:test_entity" } +func (tntTestEntityType) BBox(world.Entity) cube.BBox { return cube.Box(0, 0, 0, 0, 0, 0) } +func (tntTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} +func (tntTestEntityType) EncodeNBT(*world.EntityData) map[string]any { return nil } +func (tntTestEntityType) Apply(*world.EntityData) {} + +func TestTNTExplosionWithUnavailableSourceRemainsShieldBlockable(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + source := world.EntitySpawnOpts{}.New(tntTestEntityType{}, tntTestEntityType{}) + + <-w.Exec(func(tx *world.Tx) { + conf := tntExplosionConfig(tx, source, true) + if conf.UnblockableByShield { + t.Fatal("expected source-ignited TNT to remain shield blockable even if its source entity is unavailable") + } + if conf.Source != nil { + t.Fatal("expected unavailable source entity not to be attached to the explosion config") + } + }) +} + +func TestTNTExplosionWithoutSourceIsUnblockableByShield(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + conf := tntExplosionConfig(tx, nil, false) + if !conf.UnblockableByShield { + t.Fatal("expected source-less TNT to be unblockable by shields") + } + }) +} diff --git a/server/player/player.go b/server/player/player.go index db16f3b1b..0bdf4bbc3 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -707,9 +707,10 @@ func (p *Player) Explode(explosionPos mgl64.Vec3, impact float64, c block.Explos BlockableByShield: !c.UnblockableByShield, Source: c.Source, }) - if !shieldBlocked { - p.knockBack(explosionPos, impact, diff[1]/diff.Len()*impact) + if shieldBlocked { + impact *= shieldExplosionKnockBackMultiplier } + p.knockBack(explosionPos, impact, diff[1]/diff.Len()*impact) } // SetAbsorption sets the absorption health of a player. This extra health shows as golden hearts and do not @@ -1478,10 +1479,10 @@ func (p *Player) GameMode() world.GameMode { // HasCooldown returns true if the item passed has an active cooldown, meaning it currently cannot be used again. If the // world.Item passed is nil, HasCooldown always returns false. func (p *Player) HasCooldown(item world.Item) bool { - return p.hasCooldownAt(item, time.Now()) + return p.hasCooldownAt(item, time.Now(), true) } -func (p *Player) hasCooldownAt(item world.Item, now time.Time) bool { +func (p *Player) hasCooldownAt(item world.Item, now time.Time, cleanExpired bool) bool { if item == nil { return false } @@ -1491,6 +1492,9 @@ func (p *Player) hasCooldownAt(item world.Item, now time.Time) bool { return false } if now.After(otherTime) { + if !cleanExpired { + return false + } delete(p.cooldowns, name) return false } @@ -1530,7 +1534,7 @@ func (p *Player) UseItem() { } i, left := p.HeldItems() it := i.Item() - if p.StartShieldBlockingInput() { + if p.startShieldBlockingInput(i) { return } diff --git a/server/player/shield.go b/server/player/shield.go index 02c2ea4ee..54ccca8ae 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -6,6 +6,7 @@ import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/entity" + "github.com/df-mc/dragonfly/server/event" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/world" @@ -14,10 +15,11 @@ import ( ) const ( - shieldBlockDelay = time.Second / 4 - shieldDisableCooldown = 5 * time.Second - shieldDamageThreshold = 3 - shieldItemName = "minecraft:shield" + shieldBlockDelay = time.Second / 4 + shieldDisableCooldown = 5 * time.Second + shieldExplosionKnockBackMultiplier = 0.2 + shieldDamageThreshold = 3 + shieldItemName = "minecraft:shield" shieldAttackerKnockBackForce = 0.4 shieldAttackerKnockBackHeight = 0.4 @@ -40,11 +42,16 @@ type shieldKnockBacker interface { // Blocking returns true if the player is currently blocking with a shield. func (p *Player) Blocking() bool { + return p.ShieldBlocking() +} + +// ShieldBlocking returns true if the player is currently blocking with a shield. +func (p *Player) ShieldBlocking() bool { return p.shieldBlockingAt(time.Now()) } func (p *Player) shieldBlockingAt(now time.Time) bool { - if p.shieldBlockingSince.IsZero() || !p.canBlockWithShieldAt(now) { + if p.shieldBlockingSince.IsZero() || !p.canBlockWithShieldAt(now, false) { return false } return !now.Before(p.shieldBlockingSince.Add(shieldBlockDelay)) @@ -52,7 +59,7 @@ func (p *Player) shieldBlockingAt(now time.Time) bool { func (p *Player) updateShieldBlockingState(now time.Time) bool { wasPrepared, wasBlocking := !p.shieldBlockingSince.IsZero(), p.shieldBlockingCached - if !p.canBlockWithShieldAt(now) { + if !p.canBlockWithShieldAt(now, true) { p.shieldBlockingSince = time.Time{} p.shieldBlockingCached = false return wasPrepared || wasBlocking @@ -71,8 +78,8 @@ func (p *Player) resetShieldBlocking() bool { return wasPrepared || wasBlocking } -func (p *Player) canBlockWithShieldAt(now time.Time) bool { - if (!p.Sneaking() && !p.shieldBlockingInput) || p.hasCooldownAt(item.Shield{}, now) { +func (p *Player) canBlockWithShieldAt(now time.Time, cleanExpiredCooldown bool) bool { + if (!p.Sneaking() && !p.shieldBlockingInput) || p.hasCooldownAt(item.Shield{}, now, cleanExpiredCooldown) { return false } _, _, ok := p.heldShield() @@ -203,6 +210,17 @@ func (p *Player) useItemStartsShieldBlocking(mainHand item.Stack) bool { // StartShieldBlockingInput starts shield blocking from an item-use input if the held items allow it. func (p *Player) StartShieldBlockingInput() bool { mainHand, _ := p.HeldItems() + if p.HasCooldown(mainHand.Item()) { + return false + } + ctx := event.C(p) + if p.Handler().HandleItemUse(ctx); ctx.Cancelled() { + return false + } + return p.startShieldBlockingInput(mainHand) +} + +func (p *Player) startShieldBlockingInput(mainHand item.Stack) bool { if !p.useItemStartsShieldBlocking(mainHand) { return false } @@ -238,8 +256,8 @@ func (p *Player) blockDamageWithShield(dmg float64, src world.DamageSource) bool if damage := shieldDurabilityDamage(dmg); damage > 0 { p.setHeldShield(hand, p.damageItem(shield, damage)) } - if s, ok := src.(entity.ProjectileDamageSource); ok && s.ShieldBlockHandler != nil { - s.ShieldBlockHandler.HandleShieldBlock() + if s, ok := src.(entity.ProjectileDamageSource); ok && s.ShieldBlockMarker != nil { + s.ShieldBlockMarker.MarkShieldBlocked() } if p.tx != nil { p.tx.PlaySound(p.Position(), sound.ShieldBlock{}) diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 00e9d90ff..a9804f5c3 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -145,6 +145,19 @@ func TestStartShieldBlockingInputHonoursUsePriority(t *testing.T) { } } +func TestStartShieldBlockingInputHonoursCancelledItemUse(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.h = cancellingItemUseHandler{} + + if p.StartShieldBlockingInput() { + t.Fatal("expected cancelled item use not to start shield blocking input") + } + if p.shieldBlockingInput { + t.Fatal("expected shield input to stay inactive after cancelled item use") + } +} + func TestStopSneakingPreservesHeldShieldInput(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.shieldBlockingInput = true @@ -247,20 +260,16 @@ func TestShieldBlocksZeroDamageProjectile(t *testing.T) { projectile := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} handler := &shieldBlockTestHandler{} - if dmg, vulnerable := p.Hurt(0, entity.ProjectileDamageSource{Projectile: projectile, ShieldBlockHandler: handler}); dmg != 0 || vulnerable { + if dmg, vulnerable := p.Hurt(0, entity.ProjectileDamageSource{Projectile: projectile, ShieldBlockMarker: &handler.ProjectileShieldBlockMarker}); dmg != 0 || vulnerable { t.Fatalf("expected shield-blocked zero damage projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) } - if !handler.blocked { + if !handler.ShieldBlocked() { t.Fatal("expected zero damage projectile shield block callback to run") } } type shieldBlockTestHandler struct { - blocked bool -} - -func (h *shieldBlockTestHandler) HandleShieldBlock() { - h.blocked = true + entity.ProjectileShieldBlockMarker } func TestShieldDurabilityUsesDamageBeforeArmourReduction(t *testing.T) { @@ -299,6 +308,34 @@ func TestExplosionKnockBackNotSuppressedByNestedShieldBlock(t *testing.T) { } } +func TestShieldBlockedExplosionAppliesReducedKnockBack(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.data.Pos = mgl64.Vec3{0, 0, 1} + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + + unblocked := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.Stack{}) + unblocked.data.Pos = p.data.Pos + w := world.New() + defer func() { + _ = w.Close() + }() + explosionPos := mgl64.Vec3{0, 0, 4} + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + unblocked.tx = tx + + conf := block.ExplosionConfig{Size: 1} + p.Explode(explosionPos, 0.2, conf) + unblocked.Explode(explosionPos, 0.2, conf) + }) + if p.Velocity().Len() == 0 { + t.Fatal("expected shield-blocked explosion to still apply reduced knockback") + } + if p.Velocity().Len() >= unblocked.Velocity().Len() { + t.Fatalf("expected shield-blocked explosion knockback %v to be less than unblocked knockback %v", p.Velocity(), unblocked.Velocity()) + } +} + type cancellingHurtHandler struct { NopHandler } @@ -307,6 +344,14 @@ func (cancellingHurtHandler) HandleHurt(ctx *Context, _ *float64, _ bool, _ *tim ctx.Cancel() } +type cancellingItemUseHandler struct { + NopHandler +} + +func (cancellingItemUseHandler) HandleItemUse(ctx *Context) { + ctx.Cancel() +} + type nestedShieldBlockHandler struct { NopHandler player *Player @@ -369,3 +414,57 @@ func TestShieldDurabilityDamage(t *testing.T) { } } } + +func TestShieldBlockingReadDoesNotClearExpiredCooldown(t *testing.T) { + now := time.Now() + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = now.Add(-shieldBlockDelay) + p.cooldowns[shieldItemName] = now.Add(-time.Second) + + if !p.Blocking() { + t.Fatal("expected expired shield cooldown not to prevent blocking") + } + if _, ok := p.cooldowns[shieldItemName]; !ok { + t.Fatal("expected shield blocking metadata read not to mutate cooldown state") + } +} + +func TestShouldAttemptShieldBlockWithHandlerMutatedDamage(t *testing.T) { + for _, test := range []struct { + name string + rawDamage float64 + damageLeft float64 + damageBeforeHandler float64 + src world.DamageSource + want bool + }{ + { + name: "positive damage reduced to zero", + rawDamage: 4, + damageLeft: 0, + damageBeforeHandler: 4, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + }, + { + name: "zero damage increased", + damageLeft: 2, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + want: true, + }, + { + name: "negative damage", + rawDamage: -1, + damageLeft: -1, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + }, + { + name: "zero damage projectile", + src: entity.ProjectileDamageSource{Projectile: shieldTestEntity{}}, + want: true, + }, + } { + if got := shouldAttemptShieldBlock(test.rawDamage, test.damageLeft, test.damageBeforeHandler, test.src); got != test.want { + t.Fatalf("%v: expected shouldAttemptShieldBlock to return %v, got %v", test.name, test.want, got) + } + } +} diff --git a/server/session/entity_metadata.go b/server/session/entity_metadata.go index 9a3869513..40eb1ae24 100644 --- a/server/session/entity_metadata.go +++ b/server/session/entity_metadata.go @@ -81,8 +81,8 @@ func (s *Session) addSpecificMetadata(e any, m protocol.EntityMetadata) { if u, ok := e.(using); ok && u.UsingItem() { m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagUsingItem) } - if b, ok := e.(blocker); ok && b.Blocking() { - m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagBlocking) + if b, ok := e.(shieldBlocker); ok && b.ShieldBlocking() { + m.SetFlag(protocol.EntityDataKeyFlagsTwo, protocol.EntityDataFlagBlocking&63) } if c, ok := e.(arrow); ok && c.Critical() { m.SetFlag(protocol.EntityDataKeyFlags, protocol.EntityDataFlagCritical) @@ -221,8 +221,8 @@ type breather interface { MaxAirSupply() time.Duration } -type blocker interface { - Blocking() bool +type shieldBlocker interface { + ShieldBlocking() bool } type immobile interface { diff --git a/server/session/entity_metadata_test.go b/server/session/entity_metadata_test.go new file mode 100644 index 000000000..6a1c87fba --- /dev/null +++ b/server/session/entity_metadata_test.go @@ -0,0 +1,37 @@ +package session + +import ( + "testing" + + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +type metadataBlockingEntity struct{} + +func (metadataBlockingEntity) Blocking() bool { return true } + +type metadataShieldBlockingEntity struct{} + +func (metadataShieldBlockingEntity) ShieldBlocking() bool { return true } + +func TestEntityMetadataDoesNotTreatBroadBlockingAsShieldBlocking(t *testing.T) { + m := protocol.NewEntityMetadata() + var s Session + + s.addSpecificMetadata(metadataBlockingEntity{}, m) + + if m.Flag(protocol.EntityDataKeyFlagsTwo, protocol.EntityDataFlagBlocking&63) { + t.Fatal("expected broad Blocking method not to set shield blocking metadata") + } +} + +func TestEntityMetadataSetsShieldBlockingFlag(t *testing.T) { + m := protocol.NewEntityMetadata() + var s Session + + s.addSpecificMetadata(metadataShieldBlockingEntity{}, m) + + if !m.Flag(protocol.EntityDataKeyFlagsTwo, protocol.EntityDataFlagBlocking&63) { + t.Fatal("expected ShieldBlocking method to set shield blocking metadata") + } +} diff --git a/server/world/entity.go b/server/world/entity.go index 28c27415e..7f06de0cb 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -365,9 +365,9 @@ 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 - // TNTWithSource optionally creates a TNT entity with the entity that caused it to ignite. If nil, or if the - // source is nil, TNT is used instead. - TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source Entity) *EntityHandle + // TNTWithSource optionally creates a TNT entity with the entity that caused it to ignite and whether its + // explosion may be blocked by shields. If nil, TNT is used instead. + TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source Entity, blockableByShield bool) *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 Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle From 793a3990f3d10ae8f4f931ed1ae16a901def6e5f Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 21:06:48 +0000 Subject: [PATCH 03/16] Tighten shield input and TNT blockability --- server/block/explosion.go | 10 ++++++ server/block/fire.go | 4 +-- server/block/tnt.go | 2 +- server/block/tnt_test.go | 28 +++++++++++++++ server/entity/damage.go | 16 +++++++++ server/player/player.go | 7 +--- server/player/shield.go | 14 ++++++-- server/player/shield_test.go | 34 ++++++++++++++++++- server/session/handler_player_auth_input.go | 2 +- .../session/handler_player_auth_input_test.go | 9 +++++ server/world/entity.go | 4 +-- 11 files changed, 114 insertions(+), 16 deletions(-) diff --git a/server/block/explosion.go b/server/block/explosion.go index 3c5677b09..c7934cee7 100644 --- a/server/block/explosion.go +++ b/server/block/explosion.go @@ -43,6 +43,16 @@ type ExplosionConfig struct { Particle world.Particle } +// ExplosionUnblockableByShield returns true if the explosion damage should not be blockable by shields. +func (c ExplosionConfig) ExplosionUnblockableByShield() bool { + return c.UnblockableByShield +} + +// ExplosionSource returns the entity that caused the explosion, if known. +func (c ExplosionConfig) ExplosionSource() world.Entity { + return c.Source +} + // ExplodableEntity represents an entity that can be exploded. type ExplodableEntity interface { // Explode is called when an explosion occurs. The entity can then react to the explosion using the configuration diff --git a/server/block/fire.go b/server/block/fire.go index f2fa52c5d..59a6052be 100644 --- a/server/block/fire.go +++ b/server/block/fire.go @@ -56,8 +56,8 @@ func infinitelyBurning(pos cube.Pos, tx *world.Tx) bool { // burn attempts to burn a block. func (f Fire) burn(from, to cube.Pos, tx *world.Tx, r *rand.Rand, chanceBound int) { if flammable, ok := tx.Block(to).(Flammable); ok && r.IntN(chanceBound) < flammable.FlammabilityInfo().Flammability { - if _, ok := flammable.(TNT); ok { - spawnTnt(to, tx, time.Second*4, nil, true) + if t, ok := flammable.(TNT); ok { + t.Ignite(to, tx, nil) return } if r.IntN(f.Age+10) < 5 && !rainingAround(to, tx) { diff --git a/server/block/tnt.go b/server/block/tnt.go index b31b7040b..fb94e65ab 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -36,7 +36,7 @@ func (t TNT) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx // Ignite ... func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, source world.Entity) bool { - spawnTnt(pos, tx, time.Second*4, entityHandle(source), source != nil) + spawnTnt(pos, tx, time.Second*4, entityHandle(source), true) return true } diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go index 76704613f..63642e3c1 100644 --- a/server/block/tnt_test.go +++ b/server/block/tnt_test.go @@ -94,3 +94,31 @@ func TestTNTSpawnCanCreateShieldBlockableTNTWithoutSource(t *testing.T) { t.Fatal("expected source-less environmental TNT to be shield blockable") } } + +func TestTNTIgniteWithoutSourceIsShieldBlockable(t *testing.T) { + var blockable bool + var source world.Entity + registry := world.EntityRegistryConfig{ + TNT: func(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src world.Entity, blockableByShield bool) *world.EntityHandle { + source, blockable = src, blockableByShield + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + }.New([]world.EntityType{tntTestEntityType{}}) + w := world.Config{Entities: registry}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + TNT{}.Ignite(cube.Pos{}, tx, nil) + }) + if source != nil { + t.Fatalf("expected no TNT source entity, got %T", source) + } + if !blockable { + t.Fatal("expected source-less TNT ignition to be shield blockable") + } +} diff --git a/server/entity/damage.go b/server/entity/damage.go index 92a016588..0262fe9f9 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -78,6 +78,22 @@ type ( } ) +// ExplosionDamageSourceConfig is implemented by explosion configuration values that can create explosion damage sources. +type ExplosionDamageSourceConfig interface { + ExplosionUnblockableByShield() bool + ExplosionSource() world.Entity +} + +// ExplosionDamageSourceFromConfig creates an ExplosionDamageSource from an explosion position and config. +func ExplosionDamageSourceFromConfig(origin mgl64.Vec3, c ExplosionDamageSourceConfig) ExplosionDamageSource { + return ExplosionDamageSource{ + Origin: origin, + HasOrigin: true, + BlockableByShield: !c.ExplosionUnblockableByShield(), + Source: c.ExplosionSource(), + } +} + func (FallDamageSource) ReducedByArmour() bool { return false } func (FallDamageSource) ReducedByResistance() bool { return true } func (FallDamageSource) Fire() bool { return false } diff --git a/server/player/player.go b/server/player/player.go index 0bdf4bbc3..8f7060428 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -701,12 +701,7 @@ func (p *Player) FinalDamageFrom(dmg float64, src world.DamageSource) float64 { // Explode ... func (p *Player) Explode(explosionPos mgl64.Vec3, impact float64, c block.ExplosionConfig) { diff := p.Position().Sub(explosionPos) - _, _, shieldBlocked := p.hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), entity.ExplosionDamageSource{ - Origin: explosionPos, - HasOrigin: true, - BlockableByShield: !c.UnblockableByShield, - Source: c.Source, - }) + _, _, shieldBlocked := p.hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), entity.ExplosionDamageSourceFromConfig(explosionPos, c)) if shieldBlocked { impact *= shieldExplosionKnockBackMultiplier } diff --git a/server/player/shield.go b/server/player/shield.go index 54ccca8ae..2709e1a17 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -210,20 +210,28 @@ func (p *Player) useItemStartsShieldBlocking(mainHand item.Stack) bool { // StartShieldBlockingInput starts shield blocking from an item-use input if the held items allow it. func (p *Player) StartShieldBlockingInput() bool { mainHand, _ := p.HeldItems() - if p.HasCooldown(mainHand.Item()) { + if !p.canStartShieldBlockingInput(mainHand) { return false } ctx := event.C(p) - if p.Handler().HandleItemUse(ctx); ctx.Cancelled() { + p.Handler().HandleItemUse(ctx) + if ctx.Cancelled() { return false } return p.startShieldBlockingInput(mainHand) } -func (p *Player) startShieldBlockingInput(mainHand item.Stack) bool { +func (p *Player) canStartShieldBlockingInput(mainHand item.Stack) bool { if !p.useItemStartsShieldBlocking(mainHand) { return false } + return !p.HasCooldown(item.Shield{}) +} + +func (p *Player) startShieldBlockingInput(mainHand item.Stack) bool { + if !p.canStartShieldBlockingInput(mainHand) { + return false + } p.SetShieldBlockingInput(true) return true } diff --git a/server/player/shield_test.go b/server/player/shield_test.go index a9804f5c3..da7cda462 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -99,7 +99,7 @@ func TestShieldBlockingRequiresSneakingReadyShieldAndStartupDelay(t *testing.T) p.shieldBlockingInput = false p.sneaking = true - p.SetCooldown(item.Shield{}, shieldDisableCooldown) + p.cooldowns[shieldItemName] = now.Add(shieldDisableCooldown) if p.shieldBlockingAt(now.Add(shieldBlockDelay)) { t.Fatal("expected shield not to block while its item cooldown is active") } @@ -136,6 +136,8 @@ func TestReleaseItemStopsShieldBlockingInput(t *testing.T) { func TestStartShieldBlockingInputHonoursUsePriority(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.Bow{}, 1), item.NewStack(item.Shield{}, 1)) p.sneaking = false + handler := &countingItemUseHandler{} + p.h = handler if p.StartShieldBlockingInput() { t.Fatal("expected main-hand bow use to take priority over offhand shield blocking") @@ -143,6 +145,27 @@ func TestStartShieldBlockingInputHonoursUsePriority(t *testing.T) { if p.shieldBlockingInput { t.Fatal("expected shield input to stay inactive while main-hand bow use has priority") } + if handler.count != 0 { + t.Fatalf("expected item-use handler not to run for non-shield-priority use, got %v calls", handler.count) + } +} + +func TestStartShieldBlockingInputHonoursShieldCooldown(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.cooldowns[shieldItemName] = time.Now().Add(shieldDisableCooldown) + handler := &countingItemUseHandler{} + p.h = handler + + if p.StartShieldBlockingInput() { + t.Fatal("expected shield use not to start while shield is on cooldown") + } + if p.shieldBlockingInput { + t.Fatal("expected shield input to stay inactive while shield is on cooldown") + } + if handler.count != 0 { + t.Fatalf("expected item-use handler not to run while shield is on cooldown, got %v calls", handler.count) + } } func TestStartShieldBlockingInputHonoursCancelledItemUse(t *testing.T) { @@ -352,6 +375,15 @@ func (cancellingItemUseHandler) HandleItemUse(ctx *Context) { ctx.Cancel() } +type countingItemUseHandler struct { + NopHandler + count int +} + +func (h *countingItemUseHandler) HandleItemUse(*Context) { + h.count++ +} + type nestedShieldBlockHandler struct { NopHandler player *Player diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index 0673f636a..d129bf379 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -168,7 +168,7 @@ type shieldBlockingInputStarter interface { func shieldBlockingInput(flags protocol.Bitset, wasSneaking, sneaking bool) (bool, bool) { if flags.Load(packet.InputFlagSneaking) || flags.Load(packet.InputFlagSneakDown) || flags.Load(packet.InputFlagSneakCurrentRaw) { - if flags.Load(packet.InputFlagStartSneaking) && !wasSneaking && !sneaking { + if !wasSneaking && !sneaking { return false, false } return true, true diff --git a/server/session/handler_player_auth_input_test.go b/server/session/handler_player_auth_input_test.go index c071a322d..e0644b72d 100644 --- a/server/session/handler_player_auth_input_test.go +++ b/server/session/handler_player_auth_input_test.go @@ -52,3 +52,12 @@ func TestShieldBlockingInputIgnoresCancelledStartSneaking(t *testing.T) { t.Fatal("expected cancelled start sneaking not to start shield input") } } + +func TestShieldBlockingInputIgnoresHeldRawSneakAfterCancelledSneak(t *testing.T) { + flags := protocol.NewBitset(packet.PlayerAuthInputBitsetSize) + flags.Set(packet.InputFlagSneakCurrentRaw) + + if down, ok := shieldBlockingInput(flags, false, false); ok || down { + t.Fatal("expected held raw sneak after cancelled sneaking not to start shield input") + } +} diff --git a/server/world/entity.go b/server/world/entity.go index 7f06de0cb..43089fbb8 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -365,8 +365,8 @@ 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 - // TNTWithSource optionally creates a TNT entity with the entity that caused it to ignite and whether its - // explosion may be blocked by shields. If nil, TNT is used instead. + // TNTWithSource optionally creates a TNT entity with the nullable entity that caused it to ignite and whether + // its explosion may be blocked by shields. If nil, TNT is used instead. TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source Entity, blockableByShield bool) *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 From 72d16bc9c1f20990b0593264500a01227f9bf0b1 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 21:14:14 +0000 Subject: [PATCH 04/16] Clean up shield integration APIs --- server/block/explosion.go | 11 ++++++++--- server/block/tnt.go | 6 +----- server/block/tnt_test.go | 33 +++++++++++++++++++++++++++++---- server/entity/damage.go | 4 ++-- server/entity/ent.go | 6 +++--- server/entity/tnt.go | 17 ++++++----------- server/entity/tnt_test.go | 4 ++-- server/player/shield.go | 5 ----- server/player/shield_test.go | 4 ++-- server/world/entity.go | 7 ++++--- 10 files changed, 57 insertions(+), 40 deletions(-) diff --git a/server/block/explosion.go b/server/block/explosion.go index c7934cee7..139e2886e 100644 --- a/server/block/explosion.go +++ b/server/block/explosion.go @@ -43,9 +43,14 @@ type ExplosionConfig struct { Particle world.Particle } -// ExplosionUnblockableByShield returns true if the explosion damage should not be blockable by shields. -func (c ExplosionConfig) ExplosionUnblockableByShield() bool { - return c.UnblockableByShield +// BlockableByShield returns true if the explosion damage may be blocked by shields. +func (c ExplosionConfig) BlockableByShield() bool { + return !c.UnblockableByShield +} + +// SetBlockableByShield sets if the explosion damage may be blocked by shields. +func (c *ExplosionConfig) SetBlockableByShield(blockable bool) { + c.UnblockableByShield = !blockable } // ExplosionSource returns the entity that caused the explosion, if known. diff --git a/server/block/tnt.go b/server/block/tnt.go index fb94e65ab..d5c5847cf 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -99,11 +99,7 @@ func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration, source *world.Enti opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} conf := tx.World().EntityRegistry().Config() if (source != nil || blockableByShield) && conf.TNTWithSource != nil { - var e world.Entity - if source != nil { - e, _ = source.Entity(tx) - } - tx.AddEntity(conf.TNTWithSource(opts, fuse, e, blockableByShield)) + tx.AddEntity(conf.TNTWithSource(opts, fuse, source, blockableByShield)) return } tx.AddEntity(conf.TNT(opts, fuse)) diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go index 63642e3c1..bf5710686 100644 --- a/server/block/tnt_test.go +++ b/server/block/tnt_test.go @@ -69,12 +69,12 @@ func TestTNTExplosionSourceUsesExplosionConfigSource(t *testing.T) { func TestTNTSpawnCanCreateShieldBlockableTNTWithoutSource(t *testing.T) { var blockable bool - var source world.Entity + var source *world.EntityHandle registry := world.EntityRegistryConfig{ TNT: func(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { return opts.New(tntTestEntityType{}, tntTestEntityType{}) }, - TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src world.Entity, blockableByShield bool) *world.EntityHandle { + TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src *world.EntityHandle, blockableByShield bool) *world.EntityHandle { source, blockable = src, blockableByShield return opts.New(tntTestEntityType{}, tntTestEntityType{}) }, @@ -97,12 +97,12 @@ func TestTNTSpawnCanCreateShieldBlockableTNTWithoutSource(t *testing.T) { func TestTNTIgniteWithoutSourceIsShieldBlockable(t *testing.T) { var blockable bool - var source world.Entity + var source *world.EntityHandle registry := world.EntityRegistryConfig{ TNT: func(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { return opts.New(tntTestEntityType{}, tntTestEntityType{}) }, - TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src world.Entity, blockableByShield bool) *world.EntityHandle { + TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src *world.EntityHandle, blockableByShield bool) *world.EntityHandle { source, blockable = src, blockableByShield return opts.New(tntTestEntityType{}, tntTestEntityType{}) }, @@ -122,3 +122,28 @@ func TestTNTIgniteWithoutSourceIsShieldBlockable(t *testing.T) { t.Fatal("expected source-less TNT ignition to be shield blockable") } } + +func TestTNTSpawnPreservesUnavailableSourceHandle(t *testing.T) { + wantSource := newTNTTestHandle() + var gotSource *world.EntityHandle + registry := world.EntityRegistryConfig{ + TNT: func(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src *world.EntityHandle, blockableByShield bool) *world.EntityHandle { + gotSource = src + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + }.New([]world.EntityType{tntTestEntityType{}}) + w := world.Config{Entities: registry}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + spawnTnt(cube.Pos{}, tx, time.Second, wantSource, true) + }) + if gotSource != wantSource { + t.Fatalf("expected unavailable source handle %v to be passed through, got %v", wantSource, gotSource) + } +} diff --git a/server/entity/damage.go b/server/entity/damage.go index 0262fe9f9..90d01398d 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -80,7 +80,7 @@ type ( // ExplosionDamageSourceConfig is implemented by explosion configuration values that can create explosion damage sources. type ExplosionDamageSourceConfig interface { - ExplosionUnblockableByShield() bool + BlockableByShield() bool ExplosionSource() world.Entity } @@ -89,7 +89,7 @@ func ExplosionDamageSourceFromConfig(origin mgl64.Vec3, c ExplosionDamageSourceC return ExplosionDamageSource{ Origin: origin, HasOrigin: true, - BlockableByShield: !c.ExplosionUnblockableByShield(), + BlockableByShield: c.BlockableByShield(), Source: c.ExplosionSource(), } } diff --git a/server/entity/ent.go b/server/entity/ent.go index effca6d52..ab0fed2a9 100644 --- a/server/entity/ent.go +++ b/server/entity/ent.go @@ -40,10 +40,10 @@ func (e *Ent) Behaviour() Behaviour { return e.data.Data.(Behaviour) } -// ProjectileOwner returns the entity that owns this Ent, if its Behaviour tracks one. +// ProjectileOwner returns the entity that owns this Ent, if it has projectile behaviour. func (e *Ent) ProjectileOwner() *world.EntityHandle { - if owner, ok := e.Behaviour().(interface{ Owner() *world.EntityHandle }); ok { - return owner.Owner() + if projectile, ok := e.Behaviour().(*ProjectileBehaviour); ok { + return projectile.Owner() } return nil } diff --git a/server/entity/tnt.go b/server/entity/tnt.go index 19b6cea16..d3a16d88f 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -16,9 +16,9 @@ func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle return newTNTWithSourceHandle(opts, fuse, nil, false) } -// NewTNTWithSource creates a new primed TNT entity with the entity that caused it to ignite. -func NewTNTWithSource(opts world.EntitySpawnOpts, fuse time.Duration, source world.Entity, blockableByShield bool) *world.EntityHandle { - return newTNTWithSourceHandle(opts, fuse, entityHandle(source), blockableByShield) +// NewTNTWithSource creates a new primed TNT entity with the entity handle that caused it to ignite. +func NewTNTWithSource(opts world.EntitySpawnOpts, fuse time.Duration, source *world.EntityHandle, blockableByShield bool) *world.EntityHandle { + return newTNTWithSourceHandle(opts, fuse, source, blockableByShield) } func newTNTWithSourceHandle(opts world.EntitySpawnOpts, fuse time.Duration, source *world.EntityHandle, blockableByShield bool) *world.EntityHandle { @@ -34,13 +34,6 @@ func newTNTWithSourceHandle(opts world.EntitySpawnOpts, fuse time.Duration, sour return opts.New(TNTType, conf) } -func entityHandle(e world.Entity) *world.EntityHandle { - if e == nil { - return nil - } - return e.H() -} - var tntConf = PassiveBehaviourConfig{ Gravity: 0.04, Drag: 0.02, @@ -59,7 +52,9 @@ func tntExplosionConfig(tx *world.Tx, source *world.EntityHandle, blockableByShi if source != nil { sourceEntity, _ = source.Entity(tx) } - return block.ExplosionConfig{ItemDropChance: 1, UnblockableByShield: !blockableByShield, Source: sourceEntity} + c := block.ExplosionConfig{ItemDropChance: 1, Source: sourceEntity} + c.SetBlockableByShield(blockableByShield) + return c } // TNTType is a world.EntityType implementation for TNT. diff --git a/server/entity/tnt_test.go b/server/entity/tnt_test.go index be9d89e2b..0b771a712 100644 --- a/server/entity/tnt_test.go +++ b/server/entity/tnt_test.go @@ -36,7 +36,7 @@ func TestTNTExplosionWithUnavailableSourceRemainsShieldBlockable(t *testing.T) { }) } -func TestTNTExplosionWithoutSourceIsUnblockableByShield(t *testing.T) { +func TestTNTExplosionConfigHonoursBlockabilityInput(t *testing.T) { w := world.New() defer func() { _ = w.Close() @@ -45,7 +45,7 @@ func TestTNTExplosionWithoutSourceIsUnblockableByShield(t *testing.T) { <-w.Exec(func(tx *world.Tx) { conf := tntExplosionConfig(tx, nil, false) if !conf.UnblockableByShield { - t.Fatal("expected source-less TNT to be unblockable by shields") + t.Fatal("expected TNT configured as shield-unblockable to remain unblockable") } }) } diff --git a/server/player/shield.go b/server/player/shield.go index 2709e1a17..078349899 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -40,11 +40,6 @@ type shieldKnockBacker interface { KnockBack(src mgl64.Vec3, force, height float64) } -// Blocking returns true if the player is currently blocking with a shield. -func (p *Player) Blocking() bool { - return p.ShieldBlocking() -} - // ShieldBlocking returns true if the player is currently blocking with a shield. func (p *Player) ShieldBlocking() bool { return p.shieldBlockingAt(time.Now()) diff --git a/server/player/shield_test.go b/server/player/shield_test.go index da7cda462..85a927947 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -128,7 +128,7 @@ func TestReleaseItemStopsShieldBlockingInput(t *testing.T) { if p.shieldBlockingInput { t.Fatal("expected releasing item to stop shield blocking input") } - if p.Blocking() { + if p.ShieldBlocking() { t.Fatal("expected shield not to block after use input is released") } } @@ -453,7 +453,7 @@ func TestShieldBlockingReadDoesNotClearExpiredCooldown(t *testing.T) { p.shieldBlockingSince = now.Add(-shieldBlockDelay) p.cooldowns[shieldItemName] = now.Add(-time.Second) - if !p.Blocking() { + if !p.ShieldBlocking() { t.Fatal("expected expired shield cooldown not to prevent blocking") } if _, ok := p.cooldowns[shieldItemName]; !ok { diff --git a/server/world/entity.go b/server/world/entity.go index 43089fbb8..ed659acc6 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -365,9 +365,10 @@ 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 - // TNTWithSource optionally creates a TNT entity with the nullable entity that caused it to ignite and whether - // its explosion may be blocked by shields. If nil, TNT is used instead. - TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source Entity, blockableByShield bool) *EntityHandle + // TNTWithSource optionally creates a TNT entity with the nullable handle that caused it to ignite and whether + // its explosion may be blocked by shields. If nil, TNT is used instead, losing source attribution and + // shield-blockability for source-aware TNT. + TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source *EntityHandle, blockableByShield bool) *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 Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle From 74e6c3c0a166895db0a09d476cd0e511bd05518b Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 21:19:58 +0000 Subject: [PATCH 05/16] Default TNT explosions to shield blockable --- server/block/tnt.go | 2 +- server/entity/damage.go | 3 +++ server/entity/tnt.go | 4 ++-- server/entity/tnt_test.go | 28 ++++++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/server/block/tnt.go b/server/block/tnt.go index d5c5847cf..33d379d78 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -42,7 +42,7 @@ func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, source world.Entity) bool { // Explode ... func (t TNT) Explode(_ mgl64.Vec3, pos cube.Pos, tx *world.Tx, c ExplosionConfig) { - spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2))), tntExplosionSourceHandle(c), !c.UnblockableByShield) + spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2))), tntExplosionSourceHandle(c), c.BlockableByShield()) } // BreakInfo ... diff --git a/server/entity/damage.go b/server/entity/damage.go index 90d01398d..8cd22373a 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -86,6 +86,9 @@ type ExplosionDamageSourceConfig interface { // ExplosionDamageSourceFromConfig creates an ExplosionDamageSource from an explosion position and config. func ExplosionDamageSourceFromConfig(origin mgl64.Vec3, c ExplosionDamageSourceConfig) ExplosionDamageSource { + if c == nil { + return ExplosionDamageSource{Origin: origin, HasOrigin: true, BlockableByShield: true} + } return ExplosionDamageSource{ Origin: origin, HasOrigin: true, diff --git a/server/entity/tnt.go b/server/entity/tnt.go index d3a16d88f..7d8548d4b 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -13,7 +13,7 @@ import ( // NewTNT creates a new primed TNT entity. func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { - return newTNTWithSourceHandle(opts, fuse, nil, false) + return newTNTWithSourceHandle(opts, fuse, nil, true) } // NewTNTWithSource creates a new primed TNT entity with the entity handle that caused it to ignite. @@ -38,7 +38,7 @@ var tntConf = PassiveBehaviourConfig{ Gravity: 0.04, Drag: 0.02, Expire: func(e *Ent, tx *world.Tx) { - explodeTNT(e, tx, nil, false) + explodeTNT(e, tx, nil, true) }, } diff --git a/server/entity/tnt_test.go b/server/entity/tnt_test.go index 0b771a712..4431d1d7c 100644 --- a/server/entity/tnt_test.go +++ b/server/entity/tnt_test.go @@ -49,3 +49,31 @@ func TestTNTExplosionConfigHonoursBlockabilityInput(t *testing.T) { } }) } + +func TestTNTExplosionConfigDefaultsToShieldBlockable(t *testing.T) { + w := world.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + conf := tntExplosionConfig(tx, nil, true) + if conf.UnblockableByShield { + t.Fatal("expected default TNT explosions to be shield blockable") + } + }) +} + +func TestExplosionDamageSourceFromNilConfigIsBlockable(t *testing.T) { + src := ExplosionDamageSourceFromConfig(cube.Pos{}.Vec3Centre(), nil) + + if !src.HasOrigin { + t.Fatal("expected nil-config explosion damage source to keep origin") + } + if !src.BlockableByShield { + t.Fatal("expected nil-config explosion damage source to default to shield blockable") + } + if src.Source != nil { + t.Fatalf("expected nil-config explosion damage source not to have a source, got %T", src.Source) + } +} From 4362b4084de3fccfa30ba0d1cdddcab2f08fa01f Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 21:28:54 +0000 Subject: [PATCH 06/16] Preserve shield interactions through immunity --- server/block/tnt.go | 2 +- server/block/tnt_test.go | 24 ++++++++++++++++++++++++ server/player/player.go | 10 +++++++--- server/player/shield.go | 3 ++- server/player/shield_test.go | 31 +++++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 5 deletions(-) diff --git a/server/block/tnt.go b/server/block/tnt.go index 33d379d78..2ee949b7f 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -98,7 +98,7 @@ func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration, source *world.Enti tx.SetBlock(pos, nil, nil) opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} conf := tx.World().EntityRegistry().Config() - if (source != nil || blockableByShield) && conf.TNTWithSource != nil { + if conf.TNTWithSource != nil { tx.AddEntity(conf.TNTWithSource(opts, fuse, source, blockableByShield)) return } diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go index bf5710686..41aa7c9bb 100644 --- a/server/block/tnt_test.go +++ b/server/block/tnt_test.go @@ -147,3 +147,27 @@ func TestTNTSpawnPreservesUnavailableSourceHandle(t *testing.T) { t.Fatalf("expected unavailable source handle %v to be passed through, got %v", wantSource, gotSource) } } + +func TestTNTSpawnPreservesSourceLessUnblockableExplosion(t *testing.T) { + var blockable bool + registry := world.EntityRegistryConfig{ + TNT: func(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src *world.EntityHandle, blockableByShield bool) *world.EntityHandle { + blockable = blockableByShield + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + }.New([]world.EntityType{tntTestEntityType{}}) + w := world.Config{Entities: registry}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + TNT{}.Explode(cube.Pos{}.Vec3Centre(), cube.Pos{}, tx, ExplosionConfig{UnblockableByShield: true}) + }) + if blockable { + t.Fatal("expected source-less unblockable explosion to prime shield-unblockable TNT") + } +} diff --git a/server/player/player.go b/server/player/player.go index 8f7060428..d3b89f7fe 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -594,9 +594,7 @@ func (p *Player) hurt(dmg float64, src world.DamageSource) (float64, bool, bool) immune := time.Now().Before(p.immuneUntil) if immune { - if damageLeft -= p.lastDamage; damageLeft <= 0 { - return 0, false, false - } + damageLeft -= p.lastDamage } immunity := time.Second / 2 @@ -608,6 +606,9 @@ func (p *Player) hurt(dmg float64, src world.DamageSource) (float64, bool, bool) if shouldAttemptShieldBlock(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { return 0, false, true } + if immune && damageLeft <= 0 { + return 0, false, false + } p.setAttackImmunity(immunity, totalDamage) if a := p.Absorption(); a > 0 { @@ -1532,6 +1533,9 @@ func (p *Player) UseItem() { if p.startShieldBlockingInput(i) { return } + if p.shieldBlockingInput && !p.useItemStartsShieldBlocking(i) { + p.SetShieldBlockingInput(false) + } if cd, ok := it.(item.Cooldown); ok { p.SetCooldown(it, cd.Cooldown()) diff --git a/server/player/shield.go b/server/player/shield.go index 078349899..69e196169 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -174,7 +174,8 @@ func shieldDurabilityDamage(dmg float64) int { func shouldAttemptShieldBlock(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { if damageLeft < 0 { - return false + _, ok := src.(entity.ProjectileDamageSource) + return ok } if damageLeft > 0 { return true diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 85a927947..7c5ddea84 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -168,6 +168,21 @@ func TestStartShieldBlockingInputHonoursShieldCooldown(t *testing.T) { } } +func TestUseItemWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.Bow{}, 1), item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.shieldBlockingInput = true + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + + p.UseItem() + if p.shieldBlockingInput { + t.Fatal("expected priority main-hand use to clear held shield input") + } + if p.ShieldBlocking() { + t.Fatal("expected priority main-hand use to stop shield blocking") + } +} + func TestStartShieldBlockingInputHonoursCancelledItemUse(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.sneaking = false @@ -291,6 +306,22 @@ func TestShieldBlocksZeroDamageProjectile(t *testing.T) { } } +func TestShieldBlocksProjectileDuringDamageImmunity(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + p.immuneUntil = time.Now().Add(time.Second) + p.lastDamage = 10 + projectile := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} + handler := &shieldBlockTestHandler{} + + if dmg, vulnerable := p.Hurt(1, entity.ProjectileDamageSource{Projectile: projectile, ShieldBlockMarker: &handler.ProjectileShieldBlockMarker}); dmg != 0 || vulnerable { + t.Fatalf("expected immune shield-blocked projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) + } + if !handler.ShieldBlocked() { + t.Fatal("expected shield-blocked projectile to be marked even during damage immunity") + } +} + type shieldBlockTestHandler struct { entity.ProjectileShieldBlockMarker } From cf14e05cf09c371b559d8f1f4f2f9c440f02a8db Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 21:41:41 +0000 Subject: [PATCH 07/16] Address shield edge cases --- server/entity/tnt.go | 60 ++++++++++++++++++++++++++------ server/entity/tnt_test.go | 23 +++++++++++++ server/player/player.go | 4 +++ server/player/shield.go | 9 ++++- server/player/shield_test.go | 67 ++++++++++++++++++++++++++++++++++++ 5 files changed, 152 insertions(+), 11 deletions(-) diff --git a/server/entity/tnt.go b/server/entity/tnt.go index 7d8548d4b..a0cf16ee4 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -17,21 +17,17 @@ func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle } // NewTNTWithSource creates a new primed TNT entity with the entity handle that caused it to ignite. +// The source is runtime-only and is not persisted through NBT reloads. func NewTNTWithSource(opts world.EntitySpawnOpts, fuse time.Duration, source *world.EntityHandle, blockableByShield bool) *world.EntityHandle { return newTNTWithSourceHandle(opts, fuse, source, blockableByShield) } func newTNTWithSourceHandle(opts world.EntitySpawnOpts, fuse time.Duration, source *world.EntityHandle, blockableByShield bool) *world.EntityHandle { - conf := tntConf - conf.ExistenceDuration = fuse - conf.Expire = func(e *Ent, tx *world.Tx) { - explodeTNT(e, tx, source, blockableByShield) - } if opts.Velocity.Len() == 0 { angle := rand.Float64() * math.Pi * 2 opts.Velocity = mgl64.Vec3{-math.Sin(angle) * 0.02, 0.1, -math.Cos(angle) * 0.02} } - return opts.New(TNTType, conf) + return opts.New(TNTType, tntBehaviourConfig{Fuse: fuse, Source: source, UnblockableByShield: !blockableByShield}) } var tntConf = PassiveBehaviourConfig{ @@ -42,6 +38,33 @@ var tntConf = PassiveBehaviourConfig{ }, } +type tntBehaviourConfig struct { + Fuse time.Duration + Source *world.EntityHandle + UnblockableByShield bool +} + +func (conf tntBehaviourConfig) Apply(data *world.EntityData) { + data.Data = conf.New() +} + +func (conf tntBehaviourConfig) New() *tntBehaviour { + b := &tntBehaviour{source: conf.Source, blockableByShield: !conf.UnblockableByShield} + confPassive := tntConf + confPassive.ExistenceDuration = conf.Fuse + confPassive.Expire = func(e *Ent, tx *world.Tx) { + explodeTNT(e, tx, b.source, b.blockableByShield) + } + b.PassiveBehaviour = confPassive.New() + return b +} + +type tntBehaviour struct { + *PassiveBehaviour + source *world.EntityHandle + blockableByShield bool +} + // explodeTNT creates an explosion at the position of e. func explodeTNT(e *Ent, tx *world.Tx, source *world.EntityHandle, blockableByShield bool) { tntExplosionConfig(tx, source, blockableByShield).Explode(tx, e.Position()) @@ -73,11 +96,28 @@ func (tntType) BBox(world.Entity) cube.BBox { } func (t tntType) DecodeNBT(m map[string]any, data *world.EntityData) { - conf := tntConf - conf.ExistenceDuration = nbtconv.TickDuration[uint8](m, "Fuse") - data.Data = conf.New() + data.Data = tntBehaviourConfig{ + Fuse: nbtconv.TickDuration[uint8](m, "Fuse"), + UnblockableByShield: nbtconv.Bool(m, "DragonflyUnblockableByShield"), + }.New() } func (tntType) EncodeNBT(data *world.EntityData) map[string]any { - return map[string]any{"Fuse": uint8(data.Data.(*PassiveBehaviour).Fuse().Milliseconds() / 50)} + fuse, blockableByShield := tntFuseAndBlockability(data.Data) + m := map[string]any{"Fuse": uint8(fuse.Milliseconds() / 50)} + if !blockableByShield { + m["DragonflyUnblockableByShield"] = uint8(1) + } + return m +} + +func tntFuseAndBlockability(data any) (time.Duration, bool) { + switch b := data.(type) { + case *tntBehaviour: + return b.Fuse(), b.blockableByShield + case *PassiveBehaviour: + return b.Fuse(), true + default: + panic("invalid TNT behaviour type") + } } diff --git a/server/entity/tnt_test.go b/server/entity/tnt_test.go index 4431d1d7c..4d39dbc3a 100644 --- a/server/entity/tnt_test.go +++ b/server/entity/tnt_test.go @@ -64,6 +64,29 @@ func TestTNTExplosionConfigDefaultsToShieldBlockable(t *testing.T) { }) } +func TestTNTNBTPreservesUnblockableShieldConfig(t *testing.T) { + var data world.EntityData + TNTType.DecodeNBT(map[string]any{ + "Fuse": uint8(5), + "DragonflyUnblockableByShield": uint8(1), + }, &data) + + encoded := TNTType.EncodeNBT(&data) + if encoded["DragonflyUnblockableByShield"] != uint8(1) { + t.Fatalf("expected saved TNT shield blockability to survive NBT round trip, got %#v", encoded["DragonflyUnblockableByShield"]) + } +} + +func TestTNTNBTDefaultsToShieldBlockable(t *testing.T) { + var data world.EntityData + TNTType.DecodeNBT(map[string]any{"Fuse": uint8(5)}, &data) + + encoded := TNTType.EncodeNBT(&data) + if _, ok := encoded["DragonflyUnblockableByShield"]; ok { + t.Fatal("expected default decoded TNT to stay shield blockable") + } +} + func TestExplosionDamageSourceFromNilConfigIsBlockable(t *testing.T) { src := ExplosionDamageSourceFromConfig(cube.Pos{}.Vec3Centre(), nil) diff --git a/server/player/player.go b/server/player/player.go index d3b89f7fe..8ac15c9d1 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1423,6 +1423,10 @@ func (p *Player) SetHeldSlot(to int) error { } *p.heldSlot = uint32(to) p.usingItem = false + mainHand, _ := p.HeldItems() + if p.shieldBlockingInput && !p.canStartShieldBlockingInput(mainHand) { + p.shieldBlockingInput = false + } shieldChanged := p.updateShieldBlockingState(time.Now()) for _, viewer := range p.viewers() { diff --git a/server/player/shield.go b/server/player/shield.go index 69e196169..828468756 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -174,8 +174,14 @@ func shieldDurabilityDamage(dmg float64) int { func shouldAttemptShieldBlock(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { if damageLeft < 0 { + if rawDamage < 0 || damageBeforeHandler > 0 { + return false + } + if rawDamage > 0 { + return true + } _, ok := src.(entity.ProjectileDamageSource) - return ok + return ok && rawDamage == 0 } if damageLeft > 0 { return true @@ -214,6 +220,7 @@ func (p *Player) StartShieldBlockingInput() bool { if ctx.Cancelled() { return false } + mainHand, _ = p.HeldItems() return p.startShieldBlockingInput(mainHand) } diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 7c5ddea84..8cb9b1b5a 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -183,6 +183,31 @@ func TestUseItemWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { } } +func TestSetHeldSlotWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.shieldBlockingInput = true + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + _ = p.inv.SetItem(1, item.NewStack(item.Bow{}, 1)) + + w := world.New() + defer func() { + _ = w.Close() + }() + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + if err := p.SetHeldSlot(1); err != nil { + t.Fatalf("expected held slot change to succeed: %v", err) + } + }) + if p.shieldBlockingInput { + t.Fatal("expected priority main-hand slot change to clear held shield input") + } + if p.ShieldBlocking() { + t.Fatal("expected priority main-hand slot change to stop shield blocking") + } +} + func TestStartShieldBlockingInputHonoursCancelledItemUse(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.sneaking = false @@ -196,6 +221,23 @@ func TestStartShieldBlockingInputHonoursCancelledItemUse(t *testing.T) { } } +func TestStartShieldBlockingInputRefreshesHeldItemsAfterHandler(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.h = &changingItemUseHandler{ + player: p, + main: item.NewStack(item.Bow{}, 1), + offHand: item.NewStack(item.Shield{}, 1), + } + + if p.StartShieldBlockingInput() { + t.Fatal("expected handler-swapped priority main-hand item to prevent shield blocking input") + } + if p.shieldBlockingInput { + t.Fatal("expected shield input to stay inactive after handler gives main-hand use priority") + } +} + func TestStopSneakingPreservesHeldShieldInput(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.shieldBlockingInput = true @@ -322,6 +364,21 @@ func TestShieldBlocksProjectileDuringDamageImmunity(t *testing.T) { } } +func TestShieldBlocksMeleeDuringDamageImmunity(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + p.immuneUntil = time.Now().Add(time.Second) + p.lastDamage = 10 + attacker := &shieldKnockBackAttacker{shieldTestEntity: shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}}} + + if dmg, vulnerable := p.Hurt(1, entity.AttackDamageSource{Attacker: attacker}); dmg != 0 || vulnerable { + t.Fatalf("expected immune shield-blocked melee hit to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) + } + if attacker.force != shieldAttackerKnockBackForce { + t.Fatal("expected shield-blocked melee attacker to be knocked back during damage immunity") + } +} + type shieldBlockTestHandler struct { entity.ProjectileShieldBlockMarker } @@ -415,6 +472,16 @@ func (h *countingItemUseHandler) HandleItemUse(*Context) { h.count++ } +type changingItemUseHandler struct { + NopHandler + player *Player + main, offHand item.Stack +} + +func (h *changingItemUseHandler) HandleItemUse(*Context) { + h.player.SetHeldItems(h.main, h.offHand) +} + type nestedShieldBlockHandler struct { NopHandler player *Player From 7ec7c3dc10c76dd003ac25e67142368f55d6cae3 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 22:04:51 +0000 Subject: [PATCH 08/16] Tighten shield edge cases --- server/block/tnt.go | 6 +- server/entity/damage.go | 39 +++--- server/entity/projectile.go | 5 +- server/entity/projectile_test.go | 15 +- server/entity/tnt_test.go | 18 +++ server/player/player.go | 44 +++++- server/player/shield.go | 18 ++- server/player/shield_test.go | 132 ++++++++++++++++-- server/session/handler_item_stack_request.go | 27 ++++ .../handler_item_stack_request_test.go | 109 +++++++++++++++ server/world/entity.go | 12 +- server/world/entity_test.go | 64 +++++++++ 12 files changed, 434 insertions(+), 55 deletions(-) create mode 100644 server/session/handler_item_stack_request_test.go create mode 100644 server/world/entity_test.go diff --git a/server/block/tnt.go b/server/block/tnt.go index 2ee949b7f..aa18ff58c 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -98,9 +98,5 @@ func spawnTnt(pos cube.Pos, tx *world.Tx, fuse time.Duration, source *world.Enti tx.SetBlock(pos, nil, nil) opts := world.EntitySpawnOpts{Position: pos.Vec3Centre()} conf := tx.World().EntityRegistry().Config() - if conf.TNTWithSource != nil { - tx.AddEntity(conf.TNTWithSource(opts, fuse, source, blockableByShield)) - return - } - tx.AddEntity(conf.TNT(opts, fuse)) + tx.AddEntity(conf.TNTWithSource(opts, fuse, source, blockableByShield)) } diff --git a/server/entity/damage.go b/server/entity/damage.go index 8cd22373a..06d3cb58c 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -1,29 +1,15 @@ package entity import ( + "sync" + "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" ) -// ProjectileShieldBlockMarker is attached to a projectile damage source so that -// the projectile can tell if its damage was blocked by a shield. -type ProjectileShieldBlockMarker struct { - shieldBlocked bool -} - -// MarkShieldBlocked marks the projectile damage as blocked by a shield. -func (m *ProjectileShieldBlockMarker) MarkShieldBlocked() { - if m != nil { - m.shieldBlocked = true - } -} - -// ShieldBlocked returns true if the projectile damage was blocked by a shield. -func (m *ProjectileShieldBlockMarker) ShieldBlocked() bool { - return m != nil && m.shieldBlocked -} +var projectileShieldBlocks sync.Map type ( // AttackDamageSource is used for damage caused by other entities, for @@ -61,8 +47,6 @@ type ( // Projectile and Owner are the world.Entity that dealt the damage and // the one that fired the projectile respectively. Projectile, Owner world.Entity - // ShieldBlockMarker is marked if the projectile damage is blocked by a shield. - ShieldBlockMarker *ProjectileShieldBlockMarker } // ExplosionDamageSource is used for damage caused by an explosion. @@ -78,6 +62,23 @@ type ( } ) +// MarkProjectileShieldBlocked marks projectile damage as blocked by a shield. +func MarkProjectileShieldBlocked(projectile world.Entity) { + if projectile == nil || projectile.H() == nil { + return + } + projectileShieldBlocks.Store(projectile.H().UUID(), struct{}{}) +} + +// ProjectileShieldBlocked returns true if projectile damage was blocked by a shield. +func ProjectileShieldBlocked(projectile world.Entity) bool { + if projectile == nil || projectile.H() == nil { + return false + } + _, ok := projectileShieldBlocks.LoadAndDelete(projectile.H().UUID()) + return ok +} + // ExplosionDamageSourceConfig is implemented by explosion configuration values that can create explosion damage sources. type ExplosionDamageSourceConfig interface { BlockableByShield() bool diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 63e3f7110..2893bc200 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -267,13 +267,12 @@ func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) bool if lt.conf.Owner != nil { owner, _ = lt.conf.Owner.Entity(e.tx) } - blockMarker := &ProjectileShieldBlockMarker{} - src := ProjectileDamageSource{Projectile: e, Owner: owner, ShieldBlockMarker: blockMarker} + src := ProjectileDamageSource{Projectile: e, Owner: owner} dmg := math.Ceil(lt.conf.Damage * vel.Len()) if lt.conf.Critical { dmg += rand.Float64() * dmg / 2 } - if _, vulnerable := l.Hurt(dmg, src); blockMarker.ShieldBlocked() { + if _, vulnerable := l.Hurt(dmg, src); ProjectileShieldBlocked(e) { lt.deflect(e, vel) return true } else if vulnerable { diff --git a/server/entity/projectile_test.go b/server/entity/projectile_test.go index d12a0856c..d71349be4 100644 --- a/server/entity/projectile_test.go +++ b/server/entity/projectile_test.go @@ -36,8 +36,8 @@ func (t *projectileShieldTarget) Speed() float64 { retur func (t *projectileShieldTarget) SetSpeed(float64) {} func (t *projectileShieldTarget) Hurt(_ float64, src world.DamageSource) (float64, bool) { - if s, ok := src.(ProjectileDamageSource); ok && t.blocked && s.ShieldBlockMarker != nil { - s.ShieldBlockMarker.MarkShieldBlocked() + if s, ok := src.(ProjectileDamageSource); ok && t.blocked { + MarkProjectileShieldBlocked(s.Projectile) } return 0, t.vulnerable } @@ -80,9 +80,16 @@ func (s projectileTestSound) Play(*world.World, mgl64.Vec3) { (*s.count)++ } +func newProjectileShieldTestEnt(pos mgl64.Vec3) *Ent { + return &Ent{ + handle: world.EntitySpawnOpts{}.New(SnowballType, ProjectileBehaviourConfig{}), + data: &world.EntityData{Pos: pos}, + } +} + func TestProjectileDeflectsAfterShieldBlock(t *testing.T) { pos := mgl64.Vec3{0, 0, 1} - projectile := &Ent{data: &world.EntityData{Pos: pos}} + projectile := newProjectileShieldTestEnt(pos) behaviour := &ProjectileBehaviour{conf: ProjectileBehaviourConfig{Damage: 2}} velocity := mgl64.Vec3{0, 0, -1} @@ -100,7 +107,7 @@ func TestProjectileDeflectsAfterShieldBlock(t *testing.T) { func TestProjectileDeflectsZeroDamageShieldBlock(t *testing.T) { pos := mgl64.Vec3{0, 0, 1} - projectile := &Ent{data: &world.EntityData{Pos: pos}} + projectile := newProjectileShieldTestEnt(pos) behaviour := &ProjectileBehaviour{conf: ProjectileBehaviourConfig{Damage: 0}} velocity := mgl64.Vec3{0, 0, -1} diff --git a/server/entity/tnt_test.go b/server/entity/tnt_test.go index 4d39dbc3a..377bc6f3d 100644 --- a/server/entity/tnt_test.go +++ b/server/entity/tnt_test.go @@ -2,6 +2,7 @@ package entity import ( "testing" + "time" "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" @@ -87,6 +88,23 @@ func TestTNTNBTDefaultsToShieldBlockable(t *testing.T) { } } +func TestTNTNBTDoesNotPersistRuntimeSource(t *testing.T) { + source := world.EntitySpawnOpts{}.New(tntTestEntityType{}, tntTestEntityType{}) + data := world.EntityData{ + Data: tntBehaviourConfig{ + Fuse: time.Second, + Source: source, + }.New(), + } + + encoded := TNTType.EncodeNBT(&data) + var decoded world.EntityData + TNTType.DecodeNBT(encoded, &decoded) + if decoded.Data.(*tntBehaviour).source != nil { + t.Fatal("expected TNT NBT decode not to restore runtime-only source handle") + } +} + func TestExplosionDamageSourceFromNilConfigIsBlockable(t *testing.T) { src := ExplosionDamageSourceFromConfig(cube.Pos{}.Vec3Centre(), nil) diff --git a/server/player/player.go b/server/player/player.go index 8ac15c9d1..0e7ce766f 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -598,8 +598,14 @@ func (p *Player) hurt(dmg float64, src world.DamageSource) (float64, bool, bool) } immunity := time.Second / 2 - ctx := event.C(p) damageBeforeHandler := damageLeft + if immune && damageLeft <= 0 { + if shouldAttemptShieldBlock(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { + return 0, false, true + } + return 0, false, false + } + ctx := event.C(p) if p.Handler().HandleHurt(ctx, &damageLeft, immune, &immunity, src); ctx.Cancelled() { return 0, false, false } @@ -1395,11 +1401,26 @@ func (p *Player) HeldItems() (mainHand, offHand item.Stack) { func (p *Player) SetHeldItems(mainHand, offHand item.Stack) { _ = p.inv.SetItem(int(*p.heldSlot), mainHand) _ = p.offHand.SetItem(0, offHand) - if changed := p.updateShieldBlockingState(time.Now()); changed && p.tx != nil { + if changed := p.updateHeldItemState(); changed && p.tx != nil { p.updateState() } } +// UpdateHeldItemState refreshes state derived from the player's currently held items. +func (p *Player) UpdateHeldItemState() { + if changed := p.updateHeldItemState(); changed && p.tx != nil { + p.updateState() + } +} + +func (p *Player) updateHeldItemState() bool { + mainHand, _ := p.HeldItems() + if p.shieldBlockingInput && !p.canStartShieldBlockingInput(mainHand) { + p.shieldBlockingInput = false + } + return p.updateShieldBlockingState(time.Now()) +} + // SetHeldSlot updates the held slot of the player to the slot provided. The // slot must be between 0 and 8. func (p *Player) SetHeldSlot(to int) error { @@ -1423,11 +1444,7 @@ func (p *Player) SetHeldSlot(to int) error { } *p.heldSlot = uint32(to) p.usingItem = false - mainHand, _ := p.HeldItems() - if p.shieldBlockingInput && !p.canStartShieldBlockingInput(mainHand) { - p.shieldBlockingInput = false - } - shieldChanged := p.updateShieldBlockingState(time.Now()) + shieldChanged := p.updateHeldItemState() for _, viewer := range p.viewers() { viewer.ViewEntityItems(p) @@ -1527,6 +1544,7 @@ func (p *Player) UseItem() { i, _ := p.HeldItems() ctx := event.C(p) if p.HasCooldown(i.Item()) { + p.startOffHandShieldBlockingInput() return } if p.Handler().HandleItemUse(ctx); ctx.Cancelled() { @@ -1547,6 +1565,9 @@ func (p *Player) UseItem() { if _, ok := it.(item.Releasable); ok { if !p.canRelease() { + if p.startOffHandShieldBlockingInput() { + return + } return } p.usingSince, p.usingItem = time.Now(), true @@ -1576,6 +1597,9 @@ func (p *Player) UseItem() { case item.Usable: useCtx := p.useContext() if !usable.Use(p.tx, p, useCtx) { + if p.startOffHandShieldBlockingInput() { + return + } return } // We only swing the player's arm if the item held actually does something. If it doesn't, there is no @@ -1585,12 +1609,18 @@ func (p *Player) UseItem() { p.addNewItem(useCtx) case item.Consumable: if c, ok := usable.(interface{ CanConsume() bool }); ok && !c.CanConsume() { + if p.startOffHandShieldBlockingInput() { + return + } p.ReleaseItem() return } if !usable.AlwaysConsumable() && p.GameMode().AllowsTakingDamage() && p.Food() >= 20 { // The item.Consumable is not always consumable, the player is not in creative mode and the // food bar is filled: The item cannot be consumed. + if p.startOffHandShieldBlockingInput() { + return + } p.ReleaseItem() return } diff --git a/server/player/shield.go b/server/player/shield.go index 828468756..2762147af 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -159,7 +159,8 @@ func shieldDisableCooldownFrom(src world.DamageSource) (time.Duration, bool) { return 0, false } mainHand, _ := attacker.HeldItems() - if _, ok := mainHand.Item().(item.Axe); !ok { + tool, ok := mainHand.Item().(item.Tool) + if !ok || tool.ToolType() != item.TypeAxe { return 0, false } return shieldDisableCooldown, true @@ -239,6 +240,17 @@ func (p *Player) startShieldBlockingInput(mainHand item.Stack) bool { return true } +func (p *Player) startOffHandShieldBlockingInput() bool { + mainHand, offHand := p.HeldItems() + if _, ok := mainHand.Item().(item.Shield); ok { + return p.startShieldBlockingInput(mainHand) + } + if _, ok := offHand.Item().(item.Shield); !ok { + return false + } + return p.startShieldBlockingInput(item.Stack{}) +} + func (p *Player) knockBackShieldAttacker(src world.DamageSource) bool { attack, ok := src.(entity.AttackDamageSource) if !ok { @@ -267,8 +279,8 @@ func (p *Player) blockDamageWithShield(dmg float64, src world.DamageSource) bool if damage := shieldDurabilityDamage(dmg); damage > 0 { p.setHeldShield(hand, p.damageItem(shield, damage)) } - if s, ok := src.(entity.ProjectileDamageSource); ok && s.ShieldBlockMarker != nil { - s.ShieldBlockMarker.MarkShieldBlocked() + if s, ok := src.(entity.ProjectileDamageSource); ok { + entity.MarkProjectileShieldBlocked(s.Projectile) } if p.tx != nil { p.tx.PlaySound(p.Position(), sound.ShieldBlock{}) diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 8cb9b1b5a..1e7b7b5de 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -15,11 +15,12 @@ import ( ) type shieldTestEntity struct { + h *world.EntityHandle pos mgl64.Vec3 } func (e shieldTestEntity) Close() error { return nil } -func (e shieldTestEntity) H() *world.EntityHandle { return nil } +func (e shieldTestEntity) H() *world.EntityHandle { return e.h } func (e shieldTestEntity) Position() mgl64.Vec3 { return e.pos } func (e shieldTestEntity) Rotation() cube.Rotation { return cube.Rotation{} @@ -28,6 +29,21 @@ func (e shieldTestEntity) HeldItems() (item.Stack, item.Stack) { return item.Stack{}, item.Stack{} } +type shieldTestEntityType struct{} + +func (shieldTestEntityType) Open(*world.Tx, *world.EntityHandle, *world.EntityData) world.Entity { + return nil +} +func (shieldTestEntityType) EncodeEntity() string { return "dragonfly:shield_test_entity" } +func (shieldTestEntityType) BBox(world.Entity) cube.BBox { return cube.Box(0, 0, 0, 0, 0, 0) } +func (shieldTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} +func (shieldTestEntityType) EncodeNBT(*world.EntityData) map[string]any { return nil } +func (shieldTestEntityType) Apply(*world.EntityData) {} + +func newShieldTestHandle() *world.EntityHandle { + return world.EntitySpawnOpts{}.New(shieldTestEntityType{}, shieldTestEntityType{}) +} + type shieldAxeAttacker struct { shieldTestEntity mainHand item.Stack @@ -173,8 +189,16 @@ func TestUseItemWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { p.sneaking = false p.shieldBlockingInput = true p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + _ = p.inv.SetItem(1, item.NewStack(item.Arrow{}, 1)) - p.UseItem() + w := world.New() + defer func() { + _ = w.Close() + }() + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + p.UseItem() + }) if p.shieldBlockingInput { t.Fatal("expected priority main-hand use to clear held shield input") } @@ -183,6 +207,27 @@ func TestUseItemWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { } } +func TestUseItemFallsBackToOffHandShieldWhenMainHandFoodCannotBeConsumed(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.Apple{}, 1), item.NewStack(item.Shield{}, 1)) + p.sneaking = false + + p.UseItem() + if !p.shieldBlockingInput { + t.Fatal("expected off-hand shield input to start when full hunger prevents main-hand food use") + } +} + +func TestUseItemFallsBackToOffHandShieldWhenMainHandItemIsOnCooldown(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.GoatHorn{}, 1), item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.SetCooldown(item.GoatHorn{}, time.Second) + + p.UseItem() + if !p.shieldBlockingInput { + t.Fatal("expected off-hand shield input to start when main-hand item is on cooldown") + } +} + func TestSetHeldSlotWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.sneaking = false @@ -208,6 +253,21 @@ func TestSetHeldSlotWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { } } +func TestSetHeldItemsWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.shieldBlockingInput = true + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + + p.SetHeldItems(item.NewStack(item.Bow{}, 1), item.NewStack(item.Shield{}, 1)) + if p.shieldBlockingInput { + t.Fatal("expected priority main-hand item update to clear held shield input") + } + if p.ShieldBlocking() { + t.Fatal("expected priority main-hand item update to stop shield blocking") + } +} + func TestStartShieldBlockingInputHonoursCancelledItemUse(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.sneaking = false @@ -337,13 +397,12 @@ func TestShieldDoesNotBlockCancelledDamage(t *testing.T) { func TestShieldBlocksZeroDamageProjectile(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) - projectile := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} - handler := &shieldBlockTestHandler{} + projectile := shieldTestEntity{h: newShieldTestHandle(), pos: mgl64.Vec3{0, 0, 4}} - if dmg, vulnerable := p.Hurt(0, entity.ProjectileDamageSource{Projectile: projectile, ShieldBlockMarker: &handler.ProjectileShieldBlockMarker}); dmg != 0 || vulnerable { + if dmg, vulnerable := p.Hurt(0, entity.ProjectileDamageSource{Projectile: projectile}); dmg != 0 || vulnerable { t.Fatalf("expected shield-blocked zero damage projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) } - if !handler.ShieldBlocked() { + if !entity.ProjectileShieldBlocked(projectile) { t.Fatal("expected zero damage projectile shield block callback to run") } } @@ -353,13 +412,12 @@ func TestShieldBlocksProjectileDuringDamageImmunity(t *testing.T) { p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) p.immuneUntil = time.Now().Add(time.Second) p.lastDamage = 10 - projectile := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} - handler := &shieldBlockTestHandler{} + projectile := shieldTestEntity{h: newShieldTestHandle(), pos: mgl64.Vec3{0, 0, 4}} - if dmg, vulnerable := p.Hurt(1, entity.ProjectileDamageSource{Projectile: projectile, ShieldBlockMarker: &handler.ProjectileShieldBlockMarker}); dmg != 0 || vulnerable { + if dmg, vulnerable := p.Hurt(1, entity.ProjectileDamageSource{Projectile: projectile}); dmg != 0 || vulnerable { t.Fatalf("expected immune shield-blocked projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) } - if !handler.ShieldBlocked() { + if !entity.ProjectileShieldBlocked(projectile) { t.Fatal("expected shield-blocked projectile to be marked even during damage immunity") } } @@ -379,8 +437,19 @@ func TestShieldBlocksMeleeDuringDamageImmunity(t *testing.T) { } } -type shieldBlockTestHandler struct { - entity.ProjectileShieldBlockMarker +func TestIgnoredImmuneHitDoesNotNotifyHurtHandler(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.Stack{}) + p.immuneUntil = time.Now().Add(time.Second) + p.lastDamage = 10 + handler := &minimumDamageHurtHandler{} + p.h = handler + + if dmg, vulnerable := p.Hurt(1, entity.AttackDamageSource{Attacker: shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}}}); dmg != 0 || vulnerable { + t.Fatalf("expected immune ignored hit to deal no damage, got damage %v vulnerable %v", dmg, vulnerable) + } + if handler.called { + t.Fatal("expected fully ignored immune hit not to notify hurt handler") + } } func TestShieldDurabilityUsesDamageBeforeArmourReduction(t *testing.T) { @@ -455,6 +524,16 @@ func (cancellingHurtHandler) HandleHurt(ctx *Context, _ *float64, _ bool, _ *tim ctx.Cancel() } +type minimumDamageHurtHandler struct { + NopHandler + called bool +} + +func (h *minimumDamageHurtHandler) HandleHurt(_ *Context, damage *float64, _ bool, _ *time.Duration, _ world.DamageSource) { + h.called = true + *damage = 1 +} + type cancellingItemUseHandler struct { NopHandler } @@ -512,6 +591,35 @@ func TestShieldDisableCooldownFromAxeAttack(t *testing.T) { } } +func TestShieldDisableCooldownFromCustomAxeToolAttack(t *testing.T) { + attacker := shieldAxeAttacker{mainHand: item.NewStack(shieldCustomAxeTool{}, 1)} + cooldown, ok := shieldDisableCooldownFrom(entity.AttackDamageSource{Attacker: attacker}) + if !ok { + t.Fatal("expected a custom axe tool attack to disable shields") + } + if cooldown != shieldDisableCooldown { + t.Fatalf("expected shield disable cooldown %v, got %v", shieldDisableCooldown, cooldown) + } +} + +type shieldCustomAxeTool struct{} + +func (shieldCustomAxeTool) EncodeItem() (string, int16) { + return "dragonfly:shield_custom_axe", 0 +} + +func (shieldCustomAxeTool) ToolType() item.ToolType { + return item.TypeAxe +} + +func (shieldCustomAxeTool) HarvestLevel() int { + return 0 +} + +func (shieldCustomAxeTool) BaseMiningEfficiency(world.Block) float64 { + return 1 +} + func TestShieldKnocksBackMeleeAttacker(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) attacker := &shieldKnockBackAttacker{shieldTestEntity: shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}}} diff --git a/server/session/handler_item_stack_request.go b/server/session/handler_item_stack_request.go index b7d50b753..75d742bef 100644 --- a/server/session/handler_item_stack_request.go +++ b/server/session/handler_item_stack_request.go @@ -424,6 +424,9 @@ func (h *ItemStackRequestHandler) setItemInSlot(slot protocol.StackRequestSlotIn before, _ := inv.Item(sl) _ = inv.SetItem(sl, i) + if s.updatesHeldItemState(inv, sl) { + s.updateHeldItemState(tx) + } respSlot := protocol.StackResponseSlotInfo{ Slot: slot.Slot, @@ -453,6 +456,30 @@ func (h *ItemStackRequestHandler) setItemInSlot(slot protocol.StackRequestSlotIn } } +type heldItemStateUpdater interface { + UpdateHeldItemState() +} + +func (s *Session) updatesHeldItemState(inv *inventory.Inventory, slot int) bool { + if inv == s.offHand { + return true + } + return inv == s.inv && s.heldSlot != nil && slot == int(*s.heldSlot) +} + +func (s *Session) updateHeldItemState(tx *world.Tx) { + if s.ent == nil { + return + } + e, ok := s.ent.Entity(tx) + if !ok { + return + } + if updater, ok := e.(heldItemStateUpdater); ok { + updater.UpdateHeldItemState() + } +} + // resolve resolves the request with the ID passed. func (h *ItemStackRequestHandler) resolve(id int32, s *Session) { info := make([]protocol.StackResponseContainerInfo, 0, len(h.changes)) diff --git a/server/session/handler_item_stack_request_test.go b/server/session/handler_item_stack_request_test.go new file mode 100644 index 000000000..58f498819 --- /dev/null +++ b/server/session/handler_item_stack_request_test.go @@ -0,0 +1,109 @@ +package session + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol" +) + +type heldItemStateTestEntity struct { + h *world.EntityHandle + updates *int +} + +func (e heldItemStateTestEntity) Close() error { return nil } +func (e heldItemStateTestEntity) H() *world.EntityHandle { return e.h } +func (e heldItemStateTestEntity) Position() mgl64.Vec3 { return mgl64.Vec3{} } +func (e heldItemStateTestEntity) Rotation() cube.Rotation { + return cube.Rotation{} +} +func (e heldItemStateTestEntity) UpdateHeldItemState() { + *e.updates++ +} + +type heldItemStateTestConfig struct { + updates *int +} + +func (c heldItemStateTestConfig) Apply(data *world.EntityData) { + data.Data = c.updates +} + +type heldItemStateTestType struct{} + +func (heldItemStateTestType) Open(_ *world.Tx, h *world.EntityHandle, data *world.EntityData) world.Entity { + return heldItemStateTestEntity{h: h, updates: data.Data.(*int)} +} +func (heldItemStateTestType) EncodeEntity() string { return "dragonfly:held_item_state_test" } +func (heldItemStateTestType) BBox(world.Entity) cube.BBox { + return cube.Box(0, 0, 0, 0, 0, 0) +} +func (heldItemStateTestType) DecodeNBT(map[string]any, *world.EntityData) {} +func (heldItemStateTestType) EncodeNBT(*world.EntityData) map[string]any { return nil } + +func TestItemStackRequestHeldSlotMutationUpdatesHeldItemState(t *testing.T) { + var updates int + handle := world.EntitySpawnOpts{}.New(heldItemStateTestType{}, heldItemStateTestConfig{updates: &updates}) + heldSlot := uint32(2) + s := &Session{ + ent: handle, + heldSlot: &heldSlot, + inv: inventory.New(36, nil), + offHand: inventory.New(1, nil), + } + h := &ItemStackRequestHandler{ + changes: map[byte]map[byte]changeInfo{}, + responseChanges: map[int32]map[*inventory.Inventory]map[byte]responseChange{}, + } + w := world.Config{Entities: world.EntityRegistryConfig{}.New([]world.EntityType{heldItemStateTestType{}})}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + tx.AddEntity(handle) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerInventory}, + Slot: byte(heldSlot), + }, item.NewStack(item.Shield{}, 1), s, tx) + }) + if updates != 1 { + t.Fatalf("expected held slot mutation to update held item state once, got %v", updates) + } +} + +func TestItemStackRequestOffHandMutationUpdatesHeldItemState(t *testing.T) { + var updates int + handle := world.EntitySpawnOpts{}.New(heldItemStateTestType{}, heldItemStateTestConfig{updates: &updates}) + heldSlot := uint32(2) + s := &Session{ + ent: handle, + heldSlot: &heldSlot, + inv: inventory.New(36, nil), + offHand: inventory.New(1, nil), + } + h := &ItemStackRequestHandler{ + changes: map[byte]map[byte]changeInfo{}, + responseChanges: map[int32]map[*inventory.Inventory]map[byte]responseChange{}, + } + w := world.Config{Entities: world.EntityRegistryConfig{}.New([]world.EntityType{heldItemStateTestType{}})}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + tx.AddEntity(handle) + h.setItemInSlot(protocol.StackRequestSlotInfo{ + Container: protocol.FullContainerName{ContainerID: protocol.ContainerOffhand}, + Slot: 0, + }, item.NewStack(item.Shield{}, 1), s, tx) + }) + if updates != 1 { + t.Fatalf("expected off-hand mutation to update held item state once, got %v", updates) + } +} diff --git a/server/world/entity.go b/server/world/entity.go index ed659acc6..02f9931a6 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -366,8 +366,8 @@ type EntityRegistryConfig struct { FallingBlock func(opts EntitySpawnOpts, bl Block) *EntityHandle TNT func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle // TNTWithSource optionally creates a TNT entity with the nullable handle that caused it to ignite and whether - // its explosion may be blocked by shields. If nil, TNT is used instead, losing source attribution and - // shield-blockability for source-aware TNT. + // its explosion may be blocked by shields. If nil, New fills a fallback that only supports source-less, + // shield-blockable TNT through TNT and panics for source-aware or shield-unblockable TNT. TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source *EntityHandle, blockableByShield bool) *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 @@ -382,6 +382,14 @@ type EntityRegistryConfig struct { // New creates an EntityRegistry using conf and the EntityTypes passed. func (conf EntityRegistryConfig) New(ent []EntityType) EntityRegistry { + if conf.TNTWithSource == nil && conf.TNT != nil { + conf.TNTWithSource = func(opts EntitySpawnOpts, fuse time.Duration, source *EntityHandle, blockableByShield bool) *EntityHandle { + if source != nil || !blockableByShield { + panic("source-aware or shield-unblockable TNT requires EntityRegistryConfig.TNTWithSource") + } + return conf.TNT(opts, fuse) + } + } m := make(map[string]EntityType, len(ent)) for _, e := range ent { name := e.EncodeEntity() diff --git a/server/world/entity_test.go b/server/world/entity_test.go new file mode 100644 index 000000000..55909d79e --- /dev/null +++ b/server/world/entity_test.go @@ -0,0 +1,64 @@ +package world + +import ( + "testing" + "time" + + "github.com/df-mc/dragonfly/server/block/cube" +) + +type entityRegistryTestType struct{} + +func (entityRegistryTestType) Open(*Tx, *EntityHandle, *EntityData) Entity { return nil } +func (entityRegistryTestType) EncodeEntity() string { return "dragonfly:entity_registry_test" } +func (entityRegistryTestType) BBox(Entity) cube.BBox { return cube.Box(0, 0, 0, 0, 0, 0) } +func (entityRegistryTestType) DecodeNBT(map[string]any, *EntityData) {} +func (entityRegistryTestType) EncodeNBT(*EntityData) map[string]any { return nil } +func (entityRegistryTestType) Apply(*EntityData) {} + +func TestEntityRegistryConfigTNTWithSourceFallbackAllowsDefaultTNT(t *testing.T) { + called := false + reg := EntityRegistryConfig{ + TNT: func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle { + called = true + return opts.New(entityRegistryTestType{}, entityRegistryTestType{}) + }, + }.New([]EntityType{entityRegistryTestType{}}) + + if h := reg.Config().TNTWithSource(EntitySpawnOpts{}, time.Second, nil, true); h == nil { + t.Fatal("expected fallback TNTWithSource to create TNT through TNT") + } + if !called { + t.Fatal("expected fallback TNTWithSource to call TNT") + } +} + +func TestEntityRegistryConfigTNTWithSourceFallbackRejectsSourceAwareTNT(t *testing.T) { + reg := EntityRegistryConfig{ + TNT: func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle { + return opts.New(entityRegistryTestType{}, entityRegistryTestType{}) + }, + }.New([]EntityType{entityRegistryTestType{}}) + + defer func() { + if recover() == nil { + t.Fatal("expected fallback TNTWithSource to reject source-aware TNT") + } + }() + reg.Config().TNTWithSource(EntitySpawnOpts{}, time.Second, EntitySpawnOpts{}.New(entityRegistryTestType{}, entityRegistryTestType{}), true) +} + +func TestEntityRegistryConfigTNTWithSourceFallbackRejectsShieldUnblockableTNT(t *testing.T) { + reg := EntityRegistryConfig{ + TNT: func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle { + return opts.New(entityRegistryTestType{}, entityRegistryTestType{}) + }, + }.New([]EntityType{entityRegistryTestType{}}) + + defer func() { + if recover() == nil { + t.Fatal("expected fallback TNTWithSource to reject shield-unblockable TNT") + } + }() + reg.Config().TNTWithSource(EntitySpawnOpts{}, time.Second, nil, false) +} From b4dc97466510282f74e96066e6e92b44b46787d3 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 22:23:17 +0000 Subject: [PATCH 09/16] Fix shield review edge cases --- server/entity/damage.go | 21 ------ server/entity/projectile.go | 18 ++++- server/entity/projectile_test.go | 14 ++-- server/player/shield.go | 6 +- server/player/shield_test.go | 61 +++++++++------ .../session/handler_inventory_transaction.go | 7 +- .../handler_inventory_transaction_test.go | 58 ++++++++++++++ server/session/handler_item_stack_request.go | 9 ++- .../handler_item_stack_request_test.go | 75 +++++++++++++++++++ server/world/entity.go | 7 +- server/world/entity_test.go | 32 ++++---- 11 files changed, 230 insertions(+), 78 deletions(-) create mode 100644 server/session/handler_inventory_transaction_test.go diff --git a/server/entity/damage.go b/server/entity/damage.go index 06d3cb58c..e933e9b97 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -1,16 +1,12 @@ package entity import ( - "sync" - "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" ) -var projectileShieldBlocks sync.Map - type ( // AttackDamageSource is used for damage caused by other entities, for // example when a player attacks another player. @@ -62,23 +58,6 @@ type ( } ) -// MarkProjectileShieldBlocked marks projectile damage as blocked by a shield. -func MarkProjectileShieldBlocked(projectile world.Entity) { - if projectile == nil || projectile.H() == nil { - return - } - projectileShieldBlocks.Store(projectile.H().UUID(), struct{}{}) -} - -// ProjectileShieldBlocked returns true if projectile damage was blocked by a shield. -func ProjectileShieldBlocked(projectile world.Entity) bool { - if projectile == nil || projectile.H() == nil { - return false - } - _, ok := projectileShieldBlocks.LoadAndDelete(projectile.H().UUID()) - return ok -} - // ExplosionDamageSourceConfig is implemented by explosion configuration values that can create explosion damage sources. type ExplosionDamageSourceConfig interface { BlockableByShield() bool diff --git a/server/entity/projectile.go b/server/entity/projectile.go index 2893bc200..2c3fdc2a2 100644 --- a/server/entity/projectile.go +++ b/server/entity/projectile.go @@ -107,8 +107,19 @@ type ProjectileBehaviour struct { ageCollided int close bool - collisionPos cube.Pos - collided bool + collisionPos cube.Pos + collided bool + shieldBlocked bool +} + +// MarkShieldBlocked marks the projectile's current hit as shield-blocked. +func (lt *ProjectileBehaviour) MarkShieldBlocked() { + lt.shieldBlocked = true +} + +// ShieldBlocked returns true if the projectile's current hit was shield-blocked. +func (lt *ProjectileBehaviour) ShieldBlocked() bool { + return lt.shieldBlocked } // Owner returns the owner of the projectile. @@ -272,7 +283,8 @@ func (lt *ProjectileBehaviour) hitEntity(l Living, e *Ent, vel mgl64.Vec3) bool if lt.conf.Critical { dmg += rand.Float64() * dmg / 2 } - if _, vulnerable := l.Hurt(dmg, src); ProjectileShieldBlocked(e) { + lt.shieldBlocked = false + if _, vulnerable := l.Hurt(dmg, src); lt.shieldBlocked { lt.deflect(e, vel) return true } else if vulnerable { diff --git a/server/entity/projectile_test.go b/server/entity/projectile_test.go index d71349be4..556278fed 100644 --- a/server/entity/projectile_test.go +++ b/server/entity/projectile_test.go @@ -37,7 +37,7 @@ func (t *projectileShieldTarget) SetSpeed(float64) {} func (t *projectileShieldTarget) Hurt(_ float64, src world.DamageSource) (float64, bool) { if s, ok := src.(ProjectileDamageSource); ok && t.blocked { - MarkProjectileShieldBlocked(s.Projectile) + s.Projectile.(*Ent).Behaviour().(interface{ MarkShieldBlocked() }).MarkShieldBlocked() } return 0, t.vulnerable } @@ -80,17 +80,17 @@ func (s projectileTestSound) Play(*world.World, mgl64.Vec3) { (*s.count)++ } -func newProjectileShieldTestEnt(pos mgl64.Vec3) *Ent { +func newProjectileShieldTestEnt(pos mgl64.Vec3, behaviour *ProjectileBehaviour) *Ent { return &Ent{ handle: world.EntitySpawnOpts{}.New(SnowballType, ProjectileBehaviourConfig{}), - data: &world.EntityData{Pos: pos}, + data: &world.EntityData{Pos: pos, Data: behaviour}, } } func TestProjectileDeflectsAfterShieldBlock(t *testing.T) { pos := mgl64.Vec3{0, 0, 1} - projectile := newProjectileShieldTestEnt(pos) - behaviour := &ProjectileBehaviour{conf: ProjectileBehaviourConfig{Damage: 2}} + behaviour := ProjectileBehaviourConfig{Damage: 2}.New() + projectile := newProjectileShieldTestEnt(pos, behaviour) velocity := mgl64.Vec3{0, 0, -1} blocked := behaviour.hitEntity(&projectileShieldTarget{blocked: true}, projectile, velocity) @@ -107,8 +107,8 @@ func TestProjectileDeflectsAfterShieldBlock(t *testing.T) { func TestProjectileDeflectsZeroDamageShieldBlock(t *testing.T) { pos := mgl64.Vec3{0, 0, 1} - projectile := newProjectileShieldTestEnt(pos) - behaviour := &ProjectileBehaviour{conf: ProjectileBehaviourConfig{Damage: 0}} + behaviour := ProjectileBehaviourConfig{Damage: 0}.New() + projectile := newProjectileShieldTestEnt(pos, behaviour) velocity := mgl64.Vec3{0, 0, -1} blocked := behaviour.hitEntity(&projectileShieldTarget{blocked: true}, projectile, velocity) diff --git a/server/player/shield.go b/server/player/shield.go index 2762147af..365875792 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -280,7 +280,11 @@ func (p *Player) blockDamageWithShield(dmg float64, src world.DamageSource) bool p.setHeldShield(hand, p.damageItem(shield, damage)) } if s, ok := src.(entity.ProjectileDamageSource); ok { - entity.MarkProjectileShieldBlocked(s.Projectile) + if projectile, ok := s.Projectile.(*entity.Ent); ok { + if marker, ok := projectile.Behaviour().(interface{ MarkShieldBlocked() }); ok { + marker.MarkShieldBlocked() + } + } } if p.tx != nil { p.tx.PlaySound(p.Position(), sound.ShieldBlock{}) diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 1e7b7b5de..4cba06303 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -29,21 +29,6 @@ func (e shieldTestEntity) HeldItems() (item.Stack, item.Stack) { return item.Stack{}, item.Stack{} } -type shieldTestEntityType struct{} - -func (shieldTestEntityType) Open(*world.Tx, *world.EntityHandle, *world.EntityData) world.Entity { - return nil -} -func (shieldTestEntityType) EncodeEntity() string { return "dragonfly:shield_test_entity" } -func (shieldTestEntityType) BBox(world.Entity) cube.BBox { return cube.Box(0, 0, 0, 0, 0, 0) } -func (shieldTestEntityType) DecodeNBT(map[string]any, *world.EntityData) {} -func (shieldTestEntityType) EncodeNBT(*world.EntityData) map[string]any { return nil } -func (shieldTestEntityType) Apply(*world.EntityData) {} - -func newShieldTestHandle() *world.EntityHandle { - return world.EntitySpawnOpts{}.New(shieldTestEntityType{}, shieldTestEntityType{}) -} - type shieldAxeAttacker struct { shieldTestEntity mainHand item.Stack @@ -397,13 +382,27 @@ func TestShieldDoesNotBlockCancelledDamage(t *testing.T) { func TestShieldBlocksZeroDamageProjectile(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) - projectile := shieldTestEntity{h: newShieldTestHandle(), pos: mgl64.Vec3{0, 0, 4}} - - if dmg, vulnerable := p.Hurt(0, entity.ProjectileDamageSource{Projectile: projectile}); dmg != 0 || vulnerable { + h := world.EntitySpawnOpts{Position: mgl64.Vec3{0, 0, 4}}.New(entity.SnowballType, entity.ProjectileBehaviourConfig{}) + w := world.New() + defer func() { + _ = w.Close() + }() + var ( + dmg float64 + vulnerable bool + shieldBlocked bool + ) + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + projectile := tx.AddEntity(h).(*entity.Ent) + dmg, vulnerable = p.Hurt(0, entity.ProjectileDamageSource{Projectile: projectile}) + shieldBlocked = projectile.Behaviour().(*entity.ProjectileBehaviour).ShieldBlocked() + }) + if dmg != 0 || vulnerable { t.Fatalf("expected shield-blocked zero damage projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) } - if !entity.ProjectileShieldBlocked(projectile) { - t.Fatal("expected zero damage projectile shield block callback to run") + if !shieldBlocked { + t.Fatal("expected zero damage projectile shield block marker to be set") } } @@ -412,12 +411,26 @@ func TestShieldBlocksProjectileDuringDamageImmunity(t *testing.T) { p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) p.immuneUntil = time.Now().Add(time.Second) p.lastDamage = 10 - projectile := shieldTestEntity{h: newShieldTestHandle(), pos: mgl64.Vec3{0, 0, 4}} - - if dmg, vulnerable := p.Hurt(1, entity.ProjectileDamageSource{Projectile: projectile}); dmg != 0 || vulnerable { + h := world.EntitySpawnOpts{Position: mgl64.Vec3{0, 0, 4}}.New(entity.SnowballType, entity.ProjectileBehaviourConfig{}) + w := world.New() + defer func() { + _ = w.Close() + }() + var ( + dmg float64 + vulnerable bool + shieldBlocked bool + ) + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + projectile := tx.AddEntity(h).(*entity.Ent) + dmg, vulnerable = p.Hurt(1, entity.ProjectileDamageSource{Projectile: projectile}) + shieldBlocked = projectile.Behaviour().(*entity.ProjectileBehaviour).ShieldBlocked() + }) + if dmg != 0 || vulnerable { t.Fatalf("expected immune shield-blocked projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) } - if !entity.ProjectileShieldBlocked(projectile) { + if !shieldBlocked { t.Fatal("expected shield-blocked projectile to be marked even during damage immunity") } } diff --git a/server/session/handler_inventory_transaction.go b/server/session/handler_inventory_transaction.go index 9cb4c3820..349f70fde 100644 --- a/server/session/handler_inventory_transaction.go +++ b/server/session/handler_inventory_transaction.go @@ -48,7 +48,7 @@ func (h *InventoryTransactionHandler) Handle(p packet.Packet, s *Session, tx *wo h.resendInventories(s) // Always resend inventories with normal transactions. Most of the time we do not use these // transactions, so we're best off making sure the client and server stay in sync. - if err := h.handleNormalTransaction(pk, s, c); err != nil { + if err := h.handleNormalTransaction(pk, s, tx, c); err != nil { s.conf.Log.Debug("process packet: InventoryTransaction: verify Normal transaction actions: " + err.Error()) } return @@ -84,7 +84,7 @@ func (h *InventoryTransactionHandler) resendInventories(s *Session) { } // handleNormalTransaction ... -func (h *InventoryTransactionHandler) handleNormalTransaction(pk *packet.InventoryTransaction, s *Session, c Controllable) error { +func (h *InventoryTransactionHandler) handleNormalTransaction(pk *packet.InventoryTransaction, s *Session, tx *world.Tx, c interface{ Drop(item.Stack) int }) error { if len(pk.Actions) != 2 { return fmt.Errorf("expected two actions for dropping an item, got %d", len(pk.Actions)) } @@ -132,6 +132,9 @@ func (h *InventoryTransactionHandler) handleNormalTransaction(pk *packet.Invento n := c.Drop(res) _ = s.inv.SetItem(slot, actual.Grow(-n)) + if s.updatesHeldItemState(s.inv, slot) { + s.updateHeldItemState(tx) + } return nil } diff --git a/server/session/handler_inventory_transaction_test.go b/server/session/handler_inventory_transaction_test.go new file mode 100644 index 000000000..a6a253c07 --- /dev/null +++ b/server/session/handler_inventory_transaction_test.go @@ -0,0 +1,58 @@ +package session + +import ( + "testing" + + "github.com/df-mc/dragonfly/server/item" + "github.com/df-mc/dragonfly/server/item/inventory" + "github.com/df-mc/dragonfly/server/world" + "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +func TestInventoryTransactionDropHeldSlotUpdatesHeldItemState(t *testing.T) { + var updates int + handle := world.EntitySpawnOpts{}.New(heldItemStateTestType{}, heldItemStateTestConfig{updates: &updates}) + heldSlot := uint32(2) + s := &Session{ + ent: handle, + heldSlot: &heldSlot, + inv: inventory.New(36, nil), + } + held := item.NewStack(item.Apple{}, 2) + _ = s.inv.SetItem(int(heldSlot), held) + w := world.Config{Entities: world.EntityRegistryConfig{}.New([]world.EntityType{heldItemStateTestType{}})}.New() + defer func() { + _ = w.Close() + }() + + var err error + <-w.Exec(func(tx *world.Tx) { + tx.AddEntity(handle) + err = (&InventoryTransactionHandler{}).handleNormalTransaction(&packet.InventoryTransaction{Actions: []protocol.InventoryAction{ + { + SourceType: protocol.InventoryActionSourceWorld, + InventorySlot: 0, + NewItem: instanceFromItem(s.br, item.NewStack(item.Apple{}, 1)), + }, + { + SourceType: protocol.InventoryActionSourceContainer, + WindowID: protocol.WindowIDInventory, + InventorySlot: heldSlot, + OldItem: instanceFromItem(s.br, held), + }, + }}, s, tx, heldItemDropper{}) + }) + if err != nil { + t.Fatalf("expected drop transaction to succeed: %v", err) + } + if updates != 1 { + t.Fatalf("expected held slot drop to update held item state once, got %v", updates) + } +} + +type heldItemDropper struct{} + +func (heldItemDropper) Drop(s item.Stack) int { + return s.Count() +} diff --git a/server/session/handler_item_stack_request.go b/server/session/handler_item_stack_request.go index 75d742bef..4b351df20 100644 --- a/server/session/handler_item_stack_request.go +++ b/server/session/handler_item_stack_request.go @@ -516,7 +516,14 @@ func (h *ItemStackRequestHandler) reject(id int32, s *Session, tx *world.Tx) { for container, slots := range h.changes { for slot, info := range slots { inv, _ := s.invByID(int32(container), tx) - _ = inv.SetItem(int(slot), info.before) + sl := int(slot) + if inv == s.offHand { + sl = 0 + } + _ = inv.SetItem(sl, info.before) + if s.updatesHeldItemState(inv, sl) { + s.updateHeldItemState(tx) + } } } diff --git a/server/session/handler_item_stack_request_test.go b/server/session/handler_item_stack_request_test.go index 58f498819..b0a1a85c5 100644 --- a/server/session/handler_item_stack_request_test.go +++ b/server/session/handler_item_stack_request_test.go @@ -9,6 +9,7 @@ import ( "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl64" "github.com/sandertv/gophertunnel/minecraft/protocol" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" ) type heldItemStateTestEntity struct { @@ -107,3 +108,77 @@ func TestItemStackRequestOffHandMutationUpdatesHeldItemState(t *testing.T) { t.Fatalf("expected off-hand mutation to update held item state once, got %v", updates) } } + +func TestItemStackRequestRejectHeldSlotRollbackUpdatesHeldItemState(t *testing.T) { + var updates int + handle := world.EntitySpawnOpts{}.New(heldItemStateTestType{}, heldItemStateTestConfig{updates: &updates}) + heldSlot := uint32(2) + s := &Session{ + ent: handle, + heldSlot: &heldSlot, + inv: inventory.New(36, nil), + offHand: inventory.New(1, nil), + packets: make(chan packet.Packet, 1), + } + before := item.NewStack(item.Shield{}, 1) + _ = s.inv.SetItem(int(heldSlot), before) + h := &ItemStackRequestHandler{ + changes: map[byte]map[byte]changeInfo{ + protocol.ContainerInventory: { + byte(heldSlot): {before: before}, + }, + }, + } + w := world.Config{Entities: world.EntityRegistryConfig{}.New([]world.EntityType{heldItemStateTestType{}})}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + tx.AddEntity(handle) + h.reject(1, s, tx) + }) + if updates != 1 { + t.Fatalf("expected held slot rollback to update held item state once, got %v", updates) + } + if got, _ := s.inv.Item(int(heldSlot)); !got.Equal(before) { + t.Fatalf("expected held slot rollback to restore %v, got %v", before, got) + } +} + +func TestItemStackRequestRejectOffHandRollbackUpdatesHeldItemState(t *testing.T) { + var updates int + handle := world.EntitySpawnOpts{}.New(heldItemStateTestType{}, heldItemStateTestConfig{updates: &updates}) + heldSlot := uint32(2) + s := &Session{ + ent: handle, + heldSlot: &heldSlot, + inv: inventory.New(36, nil), + offHand: inventory.New(1, nil), + packets: make(chan packet.Packet, 1), + } + before := item.NewStack(item.Shield{}, 1) + _ = s.offHand.SetItem(0, before) + h := &ItemStackRequestHandler{ + changes: map[byte]map[byte]changeInfo{ + protocol.ContainerOffhand: { + 0: {before: before}, + }, + }, + } + w := world.Config{Entities: world.EntityRegistryConfig{}.New([]world.EntityType{heldItemStateTestType{}})}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + tx.AddEntity(handle) + h.reject(1, s, tx) + }) + if updates != 1 { + t.Fatalf("expected off-hand rollback to update held item state once, got %v", updates) + } + if got, _ := s.offHand.Item(0); !got.Equal(before) { + t.Fatalf("expected off-hand rollback to restore %v, got %v", before, got) + } +} diff --git a/server/world/entity.go b/server/world/entity.go index 02f9931a6..033699ca0 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -366,8 +366,8 @@ type EntityRegistryConfig struct { FallingBlock func(opts EntitySpawnOpts, bl Block) *EntityHandle TNT func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle // TNTWithSource optionally creates a TNT entity with the nullable handle that caused it to ignite and whether - // its explosion may be blocked by shields. If nil, New fills a fallback that only supports source-less, - // shield-blockable TNT through TNT and panics for source-aware or shield-unblockable TNT. + // its explosion may be blocked by shields. If nil, New fills a fallback that uses TNT and ignores source + // attribution and shield-blockability arguments. TNTWithSource func(opts EntitySpawnOpts, fuse time.Duration, source *EntityHandle, blockableByShield bool) *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 @@ -384,9 +384,6 @@ type EntityRegistryConfig struct { func (conf EntityRegistryConfig) New(ent []EntityType) EntityRegistry { if conf.TNTWithSource == nil && conf.TNT != nil { conf.TNTWithSource = func(opts EntitySpawnOpts, fuse time.Duration, source *EntityHandle, blockableByShield bool) *EntityHandle { - if source != nil || !blockableByShield { - panic("source-aware or shield-unblockable TNT requires EntityRegistryConfig.TNTWithSource") - } return conf.TNT(opts, fuse) } } diff --git a/server/world/entity_test.go b/server/world/entity_test.go index 55909d79e..3c75b10ba 100644 --- a/server/world/entity_test.go +++ b/server/world/entity_test.go @@ -33,32 +33,36 @@ func TestEntityRegistryConfigTNTWithSourceFallbackAllowsDefaultTNT(t *testing.T) } } -func TestEntityRegistryConfigTNTWithSourceFallbackRejectsSourceAwareTNT(t *testing.T) { +func TestEntityRegistryConfigTNTWithSourceFallbackAllowsSourceAwareTNT(t *testing.T) { + called := false reg := EntityRegistryConfig{ TNT: func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle { + called = true return opts.New(entityRegistryTestType{}, entityRegistryTestType{}) }, }.New([]EntityType{entityRegistryTestType{}}) - defer func() { - if recover() == nil { - t.Fatal("expected fallback TNTWithSource to reject source-aware TNT") - } - }() - reg.Config().TNTWithSource(EntitySpawnOpts{}, time.Second, EntitySpawnOpts{}.New(entityRegistryTestType{}, entityRegistryTestType{}), true) + if h := reg.Config().TNTWithSource(EntitySpawnOpts{}, time.Second, EntitySpawnOpts{}.New(entityRegistryTestType{}, entityRegistryTestType{}), true); h == nil { + t.Fatal("expected fallback TNTWithSource to create TNT through TNT") + } + if !called { + t.Fatal("expected fallback TNTWithSource to call TNT") + } } -func TestEntityRegistryConfigTNTWithSourceFallbackRejectsShieldUnblockableTNT(t *testing.T) { +func TestEntityRegistryConfigTNTWithSourceFallbackAllowsShieldUnblockableTNT(t *testing.T) { + called := false reg := EntityRegistryConfig{ TNT: func(opts EntitySpawnOpts, fuse time.Duration) *EntityHandle { + called = true return opts.New(entityRegistryTestType{}, entityRegistryTestType{}) }, }.New([]EntityType{entityRegistryTestType{}}) - defer func() { - if recover() == nil { - t.Fatal("expected fallback TNTWithSource to reject shield-unblockable TNT") - } - }() - reg.Config().TNTWithSource(EntitySpawnOpts{}, time.Second, nil, false) + if h := reg.Config().TNTWithSource(EntitySpawnOpts{}, time.Second, nil, false); h == nil { + t.Fatal("expected fallback TNTWithSource to create TNT through TNT") + } + if !called { + t.Fatal("expected fallback TNTWithSource to call TNT") + } } From 421970c82b61960549940c590448a513f05ce608 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 22:40:54 +0000 Subject: [PATCH 10/16] Address shield review follow-ups --- server/block/explosion.go | 15 ------ server/block/tnt.go | 5 +- server/block/tnt_test.go | 33 ++++++++++++- server/entity/damage.go | 19 -------- server/entity/ent.go | 7 +++ server/entity/tnt.go | 4 +- server/entity/tnt_test.go | 14 ------ server/player/player.go | 32 +++++++++---- server/player/shield.go | 65 ++++++++++++++++++++------ server/player/shield_test.go | 90 ++++++++++++++++++++++++++++++++++-- 10 files changed, 203 insertions(+), 81 deletions(-) diff --git a/server/block/explosion.go b/server/block/explosion.go index 139e2886e..3c5677b09 100644 --- a/server/block/explosion.go +++ b/server/block/explosion.go @@ -43,21 +43,6 @@ type ExplosionConfig struct { Particle world.Particle } -// BlockableByShield returns true if the explosion damage may be blocked by shields. -func (c ExplosionConfig) BlockableByShield() bool { - return !c.UnblockableByShield -} - -// SetBlockableByShield sets if the explosion damage may be blocked by shields. -func (c *ExplosionConfig) SetBlockableByShield(blockable bool) { - c.UnblockableByShield = !blockable -} - -// ExplosionSource returns the entity that caused the explosion, if known. -func (c ExplosionConfig) ExplosionSource() world.Entity { - return c.Source -} - // ExplodableEntity represents an entity that can be exploded. type ExplodableEntity interface { // Explode is called when an explosion occurs. The entity can then react to the explosion using the configuration diff --git a/server/block/tnt.go b/server/block/tnt.go index aa18ff58c..ff0eb8860 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -36,13 +36,14 @@ func (t TNT) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx // Ignite ... func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, source world.Entity) bool { - spawnTnt(pos, tx, time.Second*4, entityHandle(source), true) + sourceHandle := entityHandle(source) + spawnTnt(pos, tx, time.Second*4, sourceHandle, sourceHandle != nil) return true } // Explode ... func (t TNT) Explode(_ mgl64.Vec3, pos cube.Pos, tx *world.Tx, c ExplosionConfig) { - spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2))), tntExplosionSourceHandle(c), c.BlockableByShield()) + spawnTnt(pos, tx, time.Second/2+time.Duration(rand.IntN(int(time.Second+time.Second/2))), tntExplosionSourceHandle(c), !c.UnblockableByShield) } // BreakInfo ... diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go index 41aa7c9bb..14391bd7a 100644 --- a/server/block/tnt_test.go +++ b/server/block/tnt_test.go @@ -95,7 +95,7 @@ func TestTNTSpawnCanCreateShieldBlockableTNTWithoutSource(t *testing.T) { } } -func TestTNTIgniteWithoutSourceIsShieldBlockable(t *testing.T) { +func TestTNTIgniteWithoutSourceIsShieldUnblockable(t *testing.T) { var blockable bool var source *world.EntityHandle registry := world.EntityRegistryConfig{ @@ -118,8 +118,37 @@ func TestTNTIgniteWithoutSourceIsShieldBlockable(t *testing.T) { if source != nil { t.Fatalf("expected no TNT source entity, got %T", source) } + if blockable { + t.Fatal("expected source-less TNT ignition to be shield-unblockable") + } +} + +func TestTNTIgniteWithSourceIsShieldBlockable(t *testing.T) { + source := tntSourceEntity{h: newTNTTestHandle()} + var blockable bool + var gotSource *world.EntityHandle + registry := world.EntityRegistryConfig{ + TNT: func(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + TNTWithSource: func(opts world.EntitySpawnOpts, fuse time.Duration, src *world.EntityHandle, blockableByShield bool) *world.EntityHandle { + gotSource, blockable = src, blockableByShield + return opts.New(tntTestEntityType{}, tntTestEntityType{}) + }, + }.New([]world.EntityType{tntTestEntityType{}}) + w := world.Config{Entities: registry}.New() + defer func() { + _ = w.Close() + }() + + <-w.Exec(func(tx *world.Tx) { + TNT{}.Ignite(cube.Pos{}, tx, source) + }) + if gotSource != source.H() { + t.Fatalf("expected TNT source entity %v, got %v", source.H(), gotSource) + } if !blockable { - t.Fatal("expected source-less TNT ignition to be shield blockable") + t.Fatal("expected source-aware TNT ignition to stay shield-blockable for other players") } } diff --git a/server/entity/damage.go b/server/entity/damage.go index e933e9b97..77407a18b 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -58,25 +58,6 @@ type ( } ) -// ExplosionDamageSourceConfig is implemented by explosion configuration values that can create explosion damage sources. -type ExplosionDamageSourceConfig interface { - BlockableByShield() bool - ExplosionSource() world.Entity -} - -// ExplosionDamageSourceFromConfig creates an ExplosionDamageSource from an explosion position and config. -func ExplosionDamageSourceFromConfig(origin mgl64.Vec3, c ExplosionDamageSourceConfig) ExplosionDamageSource { - if c == nil { - return ExplosionDamageSource{Origin: origin, HasOrigin: true, BlockableByShield: true} - } - return ExplosionDamageSource{ - Origin: origin, - HasOrigin: true, - BlockableByShield: c.BlockableByShield(), - Source: c.ExplosionSource(), - } -} - func (FallDamageSource) ReducedByArmour() bool { return false } func (FallDamageSource) ReducedByResistance() bool { return true } func (FallDamageSource) Fire() bool { return false } diff --git a/server/entity/ent.go b/server/entity/ent.go index ab0fed2a9..824720216 100644 --- a/server/entity/ent.go +++ b/server/entity/ent.go @@ -48,6 +48,13 @@ func (e *Ent) ProjectileOwner() *world.EntityHandle { return nil } +// MarkShieldBlocked marks the Ent's behaviour as shield-blocked if it supports projectile shield block state. +func (e *Ent) MarkShieldBlocked() { + if marker, ok := e.Behaviour().(interface{ MarkShieldBlocked() }); ok { + marker.MarkShieldBlocked() + } +} + // Explode propagates the explosion behaviour of the underlying Behaviour. func (e *Ent) Explode(src mgl64.Vec3, impact float64, conf block.ExplosionConfig) { if expl, ok := e.Behaviour().(interface { diff --git a/server/entity/tnt.go b/server/entity/tnt.go index a0cf16ee4..0522e9765 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -75,9 +75,7 @@ func tntExplosionConfig(tx *world.Tx, source *world.EntityHandle, blockableByShi if source != nil { sourceEntity, _ = source.Entity(tx) } - c := block.ExplosionConfig{ItemDropChance: 1, Source: sourceEntity} - c.SetBlockableByShield(blockableByShield) - return c + return block.ExplosionConfig{ItemDropChance: 1, Source: sourceEntity, UnblockableByShield: !blockableByShield} } // TNTType is a world.EntityType implementation for TNT. diff --git a/server/entity/tnt_test.go b/server/entity/tnt_test.go index 377bc6f3d..0b8f4c352 100644 --- a/server/entity/tnt_test.go +++ b/server/entity/tnt_test.go @@ -104,17 +104,3 @@ func TestTNTNBTDoesNotPersistRuntimeSource(t *testing.T) { t.Fatal("expected TNT NBT decode not to restore runtime-only source handle") } } - -func TestExplosionDamageSourceFromNilConfigIsBlockable(t *testing.T) { - src := ExplosionDamageSourceFromConfig(cube.Pos{}.Vec3Centre(), nil) - - if !src.HasOrigin { - t.Fatal("expected nil-config explosion damage source to keep origin") - } - if !src.BlockableByShield { - t.Fatal("expected nil-config explosion damage source to default to shield blockable") - } - if src.Source != nil { - t.Fatalf("expected nil-config explosion damage source not to have a source, got %T", src.Source) - } -} diff --git a/server/player/player.go b/server/player/player.go index 0e7ce766f..455c5f2e0 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -56,7 +56,7 @@ type playerData struct { heldSlot *uint32 sneaking, sprinting, swimming, gliding, crawling, flying, - invisible, immobile, onGround, usingItem, shieldBlockingInput, shieldBlockingCached bool + invisible, immobile, onGround, usingItem, shieldBlockingInput, shieldBlockingCached, shieldBlockingUseHandled bool sleeping bool sleepPos cube.Pos @@ -600,7 +600,7 @@ func (p *Player) hurt(dmg float64, src world.DamageSource) (float64, bool, bool) immunity := time.Second / 2 damageBeforeHandler := damageLeft if immune && damageLeft <= 0 { - if shouldAttemptShieldBlock(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { + if shouldAttemptShieldBlockBeforeHurtHandler(dmg, src) && p.blockDamageWithShield(dmg, src) { return 0, false, true } return 0, false, false @@ -609,7 +609,7 @@ func (p *Player) hurt(dmg float64, src world.DamageSource) (float64, bool, bool) if p.Handler().HandleHurt(ctx, &damageLeft, immune, &immunity, src); ctx.Cancelled() { return 0, false, false } - if shouldAttemptShieldBlock(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { + if shouldAttemptShieldBlockAfterHurtHandler(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { return 0, false, true } if immune && damageLeft <= 0 { @@ -708,7 +708,13 @@ func (p *Player) FinalDamageFrom(dmg float64, src world.DamageSource) float64 { // Explode ... func (p *Player) Explode(explosionPos mgl64.Vec3, impact float64, c block.ExplosionConfig) { diff := p.Position().Sub(explosionPos) - _, _, shieldBlocked := p.hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), entity.ExplosionDamageSourceFromConfig(explosionPos, c)) + src := entity.ExplosionDamageSource{ + Origin: explosionPos, + HasOrigin: true, + BlockableByShield: !c.UnblockableByShield, + Source: c.Source, + } + _, _, shieldBlocked := p.hurt(math.Floor((impact*impact+impact)*3.5*c.Size*2+1), src) if shieldBlocked { impact *= shieldExplosionKnockBackMultiplier } @@ -1542,13 +1548,20 @@ func (p *Player) setCooldown(item world.Item, cooldown time.Duration, updateShie // This generally happens for items such as throwable items like snowballs. func (p *Player) UseItem() { i, _ := p.HeldItems() - ctx := event.C(p) + shieldUseHandled := p.consumeShieldBlockingUseHandled(i) if p.HasCooldown(i.Item()) { - p.startOffHandShieldBlockingInput() + if shieldUseHandled { + p.startOffHandShieldBlockingInput() + } else { + p.startOffHandShieldBlockingInputAfterItemUse() + } return } - if p.Handler().HandleItemUse(ctx); ctx.Cancelled() { - return + if !shieldUseHandled { + ctx := event.C(p) + if p.Handler().HandleItemUse(ctx); ctx.Cancelled() { + return + } } i, left := p.HeldItems() it := i.Item() @@ -1742,6 +1755,9 @@ func (p *Player) SetShieldBlockingInput(down bool) { if p.shieldBlockingInput == down { return } + if !down { + p.shieldBlockingUseHandled = false + } p.shieldBlockingInput = down if changed := p.updateShieldBlockingState(time.Now()); changed && p.tx != nil { p.updateState() diff --git a/server/player/shield.go b/server/player/shield.go index 365875792..137250a00 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -70,6 +70,7 @@ func (p *Player) resetShieldBlocking() bool { wasPrepared, wasBlocking := !p.shieldBlockingSince.IsZero(), p.shieldBlockingCached p.shieldBlockingSince = time.Time{} p.shieldBlockingCached = false + p.shieldBlockingUseHandled = false return wasPrepared || wasBlocking } @@ -173,17 +174,14 @@ func shieldDurabilityDamage(dmg float64) int { return int(math.Floor(dmg)) + 1 } -func shouldAttemptShieldBlock(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { - if damageLeft < 0 { - if rawDamage < 0 || damageBeforeHandler > 0 { - return false - } - if rawDamage > 0 { - return true - } - _, ok := src.(entity.ProjectileDamageSource) - return ok && rawDamage == 0 +func shouldAttemptShieldBlockBeforeHurtHandler(rawDamage float64, src world.DamageSource) bool { + if rawDamage > 0 { + return true } + return isZeroDamageProjectile(rawDamage, src) +} + +func shouldAttemptShieldBlockAfterHurtHandler(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { if damageLeft > 0 { return true } @@ -193,6 +191,10 @@ func shouldAttemptShieldBlock(rawDamage, damageLeft, damageBeforeHandler float64 if rawDamage > 0 { return true } + return isZeroDamageProjectile(rawDamage, src) +} + +func isZeroDamageProjectile(rawDamage float64, src world.DamageSource) bool { _, ok := src.(entity.ProjectileDamageSource) return ok && rawDamage == 0 } @@ -222,7 +224,11 @@ func (p *Player) StartShieldBlockingInput() bool { return false } mainHand, _ = p.HeldItems() - return p.startShieldBlockingInput(mainHand) + if !p.startShieldBlockingInput(mainHand) { + return false + } + p.shieldBlockingUseHandled = true + return true } func (p *Player) canStartShieldBlockingInput(mainHand item.Stack) bool { @@ -251,6 +257,37 @@ func (p *Player) startOffHandShieldBlockingInput() bool { return p.startShieldBlockingInput(item.Stack{}) } +func (p *Player) startOffHandShieldBlockingInputAfterItemUse() bool { + if !p.canStartOffHandShieldBlockingInput() { + return false + } + ctx := event.C(p) + p.Handler().HandleItemUse(ctx) + if ctx.Cancelled() { + return false + } + return p.startOffHandShieldBlockingInput() +} + +func (p *Player) canStartOffHandShieldBlockingInput() bool { + mainHand, offHand := p.HeldItems() + if _, ok := mainHand.Item().(item.Shield); ok { + return p.canStartShieldBlockingInput(mainHand) + } + if _, ok := offHand.Item().(item.Shield); !ok { + return false + } + return p.canStartShieldBlockingInput(item.Stack{}) +} + +func (p *Player) consumeShieldBlockingUseHandled(mainHand item.Stack) bool { + if !p.shieldBlockingUseHandled { + return false + } + p.shieldBlockingUseHandled = false + return p.canStartShieldBlockingInput(mainHand) +} + func (p *Player) knockBackShieldAttacker(src world.DamageSource) bool { attack, ok := src.(entity.AttackDamageSource) if !ok { @@ -280,10 +317,8 @@ func (p *Player) blockDamageWithShield(dmg float64, src world.DamageSource) bool p.setHeldShield(hand, p.damageItem(shield, damage)) } if s, ok := src.(entity.ProjectileDamageSource); ok { - if projectile, ok := s.Projectile.(*entity.Ent); ok { - if marker, ok := projectile.Behaviour().(interface{ MarkShieldBlocked() }); ok { - marker.MarkShieldBlocked() - } + if marker, ok := s.Projectile.(interface{ MarkShieldBlocked() }); ok { + marker.MarkShieldBlocked() } } if p.tx != nil { diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 4cba06303..1f4fda047 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -48,6 +48,15 @@ func (a *shieldKnockBackAttacker) KnockBack(src mgl64.Vec3, force, height float6 a.src, a.force, a.height = src, force, height } +type shieldMarkedProjectile struct { + shieldTestEntity + marked bool +} + +func (p *shieldMarkedProjectile) MarkShieldBlocked() { + p.marked = true +} + func newShieldTestPlayer(rot cube.Rotation, mainHand, offHand item.Stack) *Player { heldSlot := uint32(0) inv := inventory.New(36, nil) @@ -213,6 +222,34 @@ func TestUseItemFallsBackToOffHandShieldWhenMainHandItemIsOnCooldown(t *testing. } } +func TestUseItemWithCooldownOffHandShieldHonoursCancelledItemUse(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.GoatHorn{}, 1), item.NewStack(item.Shield{}, 1)) + p.sneaking = false + p.SetCooldown(item.GoatHorn{}, time.Second) + p.h = cancellingItemUseHandler{} + + p.UseItem() + + if p.shieldBlockingInput { + t.Fatal("expected cancelled item use to prevent cooldown off-hand shield fallback") + } +} + +func TestUseItemDoesNotHandleAlreadyStartedShieldUseTwice(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + handler := &countingItemUseHandler{} + p.h = handler + + if !p.StartShieldBlockingInput() { + t.Fatal("expected auth input to start shield blocking") + } + p.UseItem() + + if handler.count != 1 { + t.Fatalf("expected shield use handler to run once, got %v calls", handler.count) + } +} + func TestSetHeldSlotWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.sneaking = false @@ -435,6 +472,19 @@ func TestShieldBlocksProjectileDuringDamageImmunity(t *testing.T) { } } +func TestShieldBlocksCustomMarkedProjectile(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + projectile := &shieldMarkedProjectile{shieldTestEntity: shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}}} + + if dmg, vulnerable := p.Hurt(1, entity.ProjectileDamageSource{Projectile: projectile}); dmg != 0 || vulnerable { + t.Fatalf("expected shield-blocked custom projectile to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) + } + if !projectile.marked { + t.Fatal("expected custom projectile shield block marker to be set") + } +} + func TestShieldBlocksMeleeDuringDamageImmunity(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) @@ -680,7 +730,41 @@ func TestShieldBlockingReadDoesNotClearExpiredCooldown(t *testing.T) { } } -func TestShouldAttemptShieldBlockWithHandlerMutatedDamage(t *testing.T) { +func TestShouldAttemptShieldBlockBeforeHurtHandler(t *testing.T) { + for _, test := range []struct { + name string + rawDamage float64 + src world.DamageSource + want bool + }{ + { + name: "positive melee damage", + rawDamage: 4, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + want: true, + }, + { + name: "zero damage projectile", + src: entity.ProjectileDamageSource{Projectile: shieldTestEntity{}}, + want: true, + }, + { + name: "zero damage melee", + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + }, + { + name: "negative damage", + rawDamage: -1, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + }, + } { + if got := shouldAttemptShieldBlockBeforeHurtHandler(test.rawDamage, test.src); got != test.want { + t.Fatalf("%v: expected shouldAttemptShieldBlockBeforeHurtHandler to return %v, got %v", test.name, test.want, got) + } + } +} + +func TestShouldAttemptShieldBlockAfterHurtHandlerWithHandlerMutatedDamage(t *testing.T) { for _, test := range []struct { name string rawDamage float64 @@ -714,8 +798,8 @@ func TestShouldAttemptShieldBlockWithHandlerMutatedDamage(t *testing.T) { want: true, }, } { - if got := shouldAttemptShieldBlock(test.rawDamage, test.damageLeft, test.damageBeforeHandler, test.src); got != test.want { - t.Fatalf("%v: expected shouldAttemptShieldBlock to return %v, got %v", test.name, test.want, got) + if got := shouldAttemptShieldBlockAfterHurtHandler(test.rawDamage, test.damageLeft, test.damageBeforeHandler, test.src); got != test.want { + t.Fatalf("%v: expected shouldAttemptShieldBlockAfterHurtHandler to return %v, got %v", test.name, test.want, got) } } } From a78b46211e7c785d087246e8afe47c3af3c21c2f Mon Sep 17 00:00:00 2001 From: Hashim Date: Sat, 9 May 2026 23:27:55 +0000 Subject: [PATCH 11/16] Keep source-less TNT shield-blockable --- server/block/tnt.go | 2 +- server/block/tnt_test.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/block/tnt.go b/server/block/tnt.go index ff0eb8860..16071f651 100644 --- a/server/block/tnt.go +++ b/server/block/tnt.go @@ -37,7 +37,7 @@ func (t TNT) Activate(pos cube.Pos, _ cube.Face, tx *world.Tx, u item.User, ctx // Ignite ... func (t TNT) Ignite(pos cube.Pos, tx *world.Tx, source world.Entity) bool { sourceHandle := entityHandle(source) - spawnTnt(pos, tx, time.Second*4, sourceHandle, sourceHandle != nil) + spawnTnt(pos, tx, time.Second*4, sourceHandle, true) return true } diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go index 14391bd7a..cc8e67d26 100644 --- a/server/block/tnt_test.go +++ b/server/block/tnt_test.go @@ -95,7 +95,7 @@ func TestTNTSpawnCanCreateShieldBlockableTNTWithoutSource(t *testing.T) { } } -func TestTNTIgniteWithoutSourceIsShieldUnblockable(t *testing.T) { +func TestTNTIgniteWithoutSourceIsShieldBlockable(t *testing.T) { var blockable bool var source *world.EntityHandle registry := world.EntityRegistryConfig{ @@ -118,8 +118,8 @@ func TestTNTIgniteWithoutSourceIsShieldUnblockable(t *testing.T) { if source != nil { t.Fatalf("expected no TNT source entity, got %T", source) } - if blockable { - t.Fatal("expected source-less TNT ignition to be shield-unblockable") + if !blockable { + t.Fatal("expected source-less TNT ignition to be shield-blockable") } } From b573345b8418ac4a44cd62ddbb25357d0c6f6130 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sun, 10 May 2026 00:02:25 +0000 Subject: [PATCH 12/16] Fix shield damage immunity edge cases --- server/player/player.go | 3 -- server/player/shield.go | 3 -- server/player/shield_test.go | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/server/player/player.go b/server/player/player.go index 455c5f2e0..13d39290f 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -612,9 +612,6 @@ func (p *Player) hurt(dmg float64, src world.DamageSource) (float64, bool, bool) if shouldAttemptShieldBlockAfterHurtHandler(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { return 0, false, true } - if immune && damageLeft <= 0 { - return 0, false, false - } p.setAttackImmunity(immunity, totalDamage) if a := p.Absorption(); a > 0 { diff --git a/server/player/shield.go b/server/player/shield.go index 137250a00..25b09c263 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -188,9 +188,6 @@ func shouldAttemptShieldBlockAfterHurtHandler(rawDamage, damageLeft, damageBefor if damageBeforeHandler > 0 { return false } - if rawDamage > 0 { - return true - } return isZeroDamageProjectile(rawDamage, src) } diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 1f4fda047..938dadd92 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -7,6 +7,7 @@ import ( "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/entity/effect" "github.com/df-mc/dragonfly/server/item" "github.com/df-mc/dragonfly/server/item/enchantment" "github.com/df-mc/dragonfly/server/item/inventory" @@ -515,6 +516,26 @@ func TestIgnoredImmuneHitDoesNotNotifyHurtHandler(t *testing.T) { } } +func TestImmuneHitZeroedByHandlerUpdatesAttackImmunity(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.Stack{}) + p.immuneUntil = time.Now().Add(time.Second) + p.lastDamage = 1 + p.h = zeroDamageHurtHandler{} + + w := world.New() + defer func() { + _ = w.Close() + }() + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + p.Hurt(4, entity.SuffocationDamageSource{}) + }) + + if p.lastDamage != 4 { + t.Fatalf("expected zeroed immune hit to refresh last damage to 4, got %v", p.lastDamage) + } +} + func TestShieldDurabilityUsesDamageBeforeArmourReduction(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) @@ -529,6 +550,27 @@ func TestShieldDurabilityUsesDamageBeforeArmourReduction(t *testing.T) { } } +func TestShieldDoesNotLoseDurabilityWhenDamageFullyReduced(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + p.effects.Add(effect.New(effect.Resistance, 5, time.Second), p) + attacker := shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}} + + w := world.New() + defer func() { + _ = w.Close() + }() + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + p.Hurt(4, entity.AttackDamageSource{Attacker: attacker}) + }) + + _, offHand := p.HeldItems() + if got, want := offHand.Durability(), item.NewStack(item.Shield{}, 1).MaxDurability(); got != want { + t.Fatalf("expected shield durability to remain %v after fully reduced damage, got %v", want, got) + } +} + func TestExplosionKnockBackNotSuppressedByNestedShieldBlock(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.data.Pos = mgl64.Vec3{0, 0, 1} @@ -597,6 +639,14 @@ func (h *minimumDamageHurtHandler) HandleHurt(_ *Context, damage *float64, _ boo *damage = 1 } +type zeroDamageHurtHandler struct { + NopHandler +} + +func (zeroDamageHurtHandler) HandleHurt(_ *Context, damage *float64, _ bool, _ *time.Duration, _ world.DamageSource) { + *damage = 0 +} + type cancellingItemUseHandler struct { NopHandler } @@ -792,6 +842,12 @@ func TestShouldAttemptShieldBlockAfterHurtHandlerWithHandlerMutatedDamage(t *tes damageLeft: -1, src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, }, + { + name: "positive raw damage fully reduced", + rawDamage: 4, + damageLeft: 0, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + }, { name: "zero damage projectile", src: entity.ProjectileDamageSource{Projectile: shieldTestEntity{}}, From a2622c9c4b3c022a3d040f4e5c687b5bba2eb3d3 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sun, 10 May 2026 00:10:03 +0000 Subject: [PATCH 13/16] Address CodeRabbit shield review comments --- server/entity/tnt.go | 8 ++++++- server/entity/tnt_test.go | 23 +++++++++++++++++++ server/player/player.go | 1 + server/player/shield_test.go | 18 +++++++++++++++ server/session/handler_player_auth_input.go | 2 +- .../session/handler_player_auth_input_test.go | 13 +++++++++++ 6 files changed, 63 insertions(+), 2 deletions(-) diff --git a/server/entity/tnt.go b/server/entity/tnt.go index 0522e9765..ac179200d 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -102,7 +102,13 @@ func (t tntType) DecodeNBT(m map[string]any, data *world.EntityData) { func (tntType) EncodeNBT(data *world.EntityData) map[string]any { fuse, blockableByShield := tntFuseAndBlockability(data.Data) - m := map[string]any{"Fuse": uint8(fuse.Milliseconds() / 50)} + ticks := fuse.Milliseconds() / 50 + if ticks < 0 { + ticks = 0 + } else if ticks > 255 { + ticks = 255 + } + m := map[string]any{"Fuse": uint8(ticks)} if !blockableByShield { m["DragonflyUnblockableByShield"] = uint8(1) } diff --git a/server/entity/tnt_test.go b/server/entity/tnt_test.go index 0b8f4c352..96c85d492 100644 --- a/server/entity/tnt_test.go +++ b/server/entity/tnt_test.go @@ -104,3 +104,26 @@ func TestTNTNBTDoesNotPersistRuntimeSource(t *testing.T) { t.Fatal("expected TNT NBT decode not to restore runtime-only source handle") } } + +func TestTNTNBTClampsFuseToUint8Range(t *testing.T) { + tests := []struct { + name string + fuse time.Duration + want uint8 + }{ + {name: "negative", fuse: -time.Second, want: 0}, + {name: "oversized", fuse: time.Second * 20, want: 255}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := world.EntityData{ + Data: tntBehaviourConfig{Fuse: tt.fuse}.New(), + } + + encoded := TNTType.EncodeNBT(&data) + if encoded["Fuse"] != tt.want { + t.Fatalf("expected fuse to encode as %v, got %#v", tt.want, encoded["Fuse"]) + } + }) + } +} diff --git a/server/player/player.go b/server/player/player.go index 13d39290f..8ba5f2a32 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1419,6 +1419,7 @@ func (p *Player) UpdateHeldItemState() { func (p *Player) updateHeldItemState() bool { mainHand, _ := p.HeldItems() if p.shieldBlockingInput && !p.canStartShieldBlockingInput(mainHand) { + p.shieldBlockingUseHandled = false p.shieldBlockingInput = false } return p.updateShieldBlockingState(time.Now()) diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 938dadd92..5ef08e94e 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -291,6 +291,24 @@ func TestSetHeldItemsWithPriorityMainHandClearsHeldShieldInput(t *testing.T) { } } +func TestSetHeldItemsClearsHandledShieldUseLatch(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.sneaking = false + handler := &countingItemUseHandler{} + p.h = handler + + if !p.StartShieldBlockingInput() { + t.Fatal("expected auth input to start shield blocking") + } + p.SetHeldItems(item.NewStack(item.Bow{}, 1), item.NewStack(item.Shield{}, 1)) + p.SetHeldItems(item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.UseItem() + + if handler.count != 2 { + t.Fatalf("expected item-use handler to run for next shield raise after held-item cancellation, got %v calls", handler.count) + } +} + func TestStartShieldBlockingInputHonoursCancelledItemUse(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.sneaking = false diff --git a/server/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index d129bf379..1785e34fa 100644 --- a/server/session/handler_player_auth_input.go +++ b/server/session/handler_player_auth_input.go @@ -167,7 +167,7 @@ type shieldBlockingInputStarter interface { } func shieldBlockingInput(flags protocol.Bitset, wasSneaking, sneaking bool) (bool, bool) { - if flags.Load(packet.InputFlagSneaking) || flags.Load(packet.InputFlagSneakDown) || flags.Load(packet.InputFlagSneakCurrentRaw) { + if flags.Load(packet.InputFlagSneaking) || flags.Load(packet.InputFlagSneakDown) || flags.Load(packet.InputFlagStartSneaking) || flags.Load(packet.InputFlagSneakCurrentRaw) { if !wasSneaking && !sneaking { return false, false } diff --git a/server/session/handler_player_auth_input_test.go b/server/session/handler_player_auth_input_test.go index e0644b72d..2b9a51212 100644 --- a/server/session/handler_player_auth_input_test.go +++ b/server/session/handler_player_auth_input_test.go @@ -43,6 +43,19 @@ func TestShieldBlockingInputIgnoresUseItem(t *testing.T) { } } +func TestShieldBlockingInputStartsOnStartSneakingOnly(t *testing.T) { + flags := protocol.NewBitset(packet.PlayerAuthInputBitsetSize) + flags.Set(packet.InputFlagStartSneaking) + + down, ok := shieldBlockingInput(flags, false, true) + if !ok { + t.Fatal("expected shield input to be updated") + } + if !down { + t.Fatal("expected start sneaking input to start shield input") + } +} + func TestShieldBlockingInputIgnoresCancelledStartSneaking(t *testing.T) { flags := protocol.NewBitset(packet.PlayerAuthInputBitsetSize) flags.Set(packet.InputFlagStartSneaking) From 4d17ec3c465c7e30851c9f46ad1cb3e2c8ea5c1a Mon Sep 17 00:00:00 2001 From: Hashim Date: Sun, 10 May 2026 00:51:52 +0000 Subject: [PATCH 14/16] Avoid offhand shield main-hand use metadata --- server/player/player.go | 6 +++++- server/player/shield_test.go | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/server/player/player.go b/server/player/player.go index 8ba5f2a32..6dd87fda1 100644 --- a/server/player/player.go +++ b/server/player/player.go @@ -1745,7 +1745,11 @@ func (p *Player) useDuration() time.Duration { // UsingItem checks if the Player is currently using an item. True is returned if the Player is currently eating an // item or using it over a longer duration such as when using a bow. func (p *Player) UsingItem() bool { - return p.usingItem || !p.shieldBlockingSince.IsZero() + if p.usingItem { + return true + } + _, hand, ok := p.heldShield() + return ok && hand == shieldHandMain && !p.shieldBlockingSince.IsZero() } // SetShieldBlockingInput updates whether the player is holding the control that raises shields. diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 5ef08e94e..a94f2f60e 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -129,6 +129,28 @@ func TestUseItemStartsShieldBlockingInput(t *testing.T) { } } +func TestOffHandShieldBlockingDoesNotReportMainHandItemUse(t *testing.T) { + now := time.Now() + p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.Bow{}, 1), item.NewStack(item.Shield{}, 1)) + p.shieldBlockingSince = now.Add(-shieldBlockDelay) + + if !p.shieldBlockingAt(now) { + t.Fatal("expected off-hand shield to block while sneaking") + } + if p.UsingItem() { + t.Fatal("expected off-hand shield blocking not to report generic main-hand item use") + } +} + +func TestMainHandShieldBlockingReportsItemUse(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.NewStack(item.Shield{}, 1), item.Stack{}) + p.shieldBlockingSince = time.Now() + + if !p.UsingItem() { + t.Fatal("expected main-hand shield blocking to report item use") + } +} + func TestReleaseItemStopsShieldBlockingInput(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.sneaking = false From 55a87c355e4efaf98322c1c54567d42080e716d5 Mon Sep 17 00:00:00 2001 From: Hashim Date: Sun, 10 May 2026 01:03:55 +0000 Subject: [PATCH 15/16] Avoid shield side effects for ignored immune melee hits --- server/player/shield.go | 8 +++----- server/player/shield_test.go | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/server/player/shield.go b/server/player/shield.go index 25b09c263..5db949917 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -174,11 +174,9 @@ func shieldDurabilityDamage(dmg float64) int { return int(math.Floor(dmg)) + 1 } -func shouldAttemptShieldBlockBeforeHurtHandler(rawDamage float64, src world.DamageSource) bool { - if rawDamage > 0 { - return true - } - return isZeroDamageProjectile(rawDamage, src) +func shouldAttemptShieldBlockBeforeHurtHandler(_ float64, src world.DamageSource) bool { + _, ok := src.(entity.ProjectileDamageSource) + return ok } func shouldAttemptShieldBlockAfterHurtHandler(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { diff --git a/server/player/shield_test.go b/server/player/shield_test.go index a94f2f60e..8027f270e 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -526,7 +526,7 @@ func TestShieldBlocksCustomMarkedProjectile(t *testing.T) { } } -func TestShieldBlocksMeleeDuringDamageImmunity(t *testing.T) { +func TestIgnoredImmuneMeleeHitDoesNotTriggerShieldBlock(t *testing.T) { p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) p.immuneUntil = time.Now().Add(time.Second) @@ -534,10 +534,14 @@ func TestShieldBlocksMeleeDuringDamageImmunity(t *testing.T) { attacker := &shieldKnockBackAttacker{shieldTestEntity: shieldTestEntity{pos: mgl64.Vec3{0, 0, 4}}} if dmg, vulnerable := p.Hurt(1, entity.AttackDamageSource{Attacker: attacker}); dmg != 0 || vulnerable { - t.Fatalf("expected immune shield-blocked melee hit to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) + t.Fatalf("expected immune ignored melee hit to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) } - if attacker.force != shieldAttackerKnockBackForce { - t.Fatal("expected shield-blocked melee attacker to be knocked back during damage immunity") + if attacker.force != 0 || attacker.height != 0 { + t.Fatalf("expected ignored immune melee hit not to knock back attacker, got %v/%v", attacker.force, attacker.height) + } + _, offHand := p.HeldItems() + if got, want := offHand.Durability(), item.NewStack(item.Shield{}, 1).MaxDurability(); got != want { + t.Fatalf("expected ignored immune melee hit not to damage shield, got durability %v want %v", got, want) } } @@ -831,6 +835,11 @@ func TestShouldAttemptShieldBlockBeforeHurtHandler(t *testing.T) { name: "positive melee damage", rawDamage: 4, src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + }, + { + name: "positive projectile damage", + rawDamage: 4, + src: entity.ProjectileDamageSource{Projectile: shieldTestEntity{}}, want: true, }, { From ac603a83d68be339d4cb8842260ca41d42470c2d Mon Sep 17 00:00:00 2001 From: Hashim Date: Sun, 10 May 2026 03:07:20 +0000 Subject: [PATCH 16/16] Handle shielded explosions during immunity --- server/entity/tnt.go | 3 --- server/player/shield.go | 8 +++++-- server/player/shield_test.go | 43 ++++++++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/server/entity/tnt.go b/server/entity/tnt.go index ac179200d..469cb69a2 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -33,9 +33,6 @@ func newTNTWithSourceHandle(opts world.EntitySpawnOpts, fuse time.Duration, sour var tntConf = PassiveBehaviourConfig{ Gravity: 0.04, Drag: 0.02, - Expire: func(e *Ent, tx *world.Tx) { - explodeTNT(e, tx, nil, true) - }, } type tntBehaviourConfig struct { diff --git a/server/player/shield.go b/server/player/shield.go index 5db949917..77254f089 100644 --- a/server/player/shield.go +++ b/server/player/shield.go @@ -175,8 +175,12 @@ func shieldDurabilityDamage(dmg float64) int { } func shouldAttemptShieldBlockBeforeHurtHandler(_ float64, src world.DamageSource) bool { - _, ok := src.(entity.ProjectileDamageSource) - return ok + switch src.(type) { + case entity.ProjectileDamageSource, entity.ExplosionDamageSource: + return true + default: + return false + } } func shouldAttemptShieldBlockAfterHurtHandler(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { diff --git a/server/player/shield_test.go b/server/player/shield_test.go index 8027f270e..9b7905bbe 100644 --- a/server/player/shield_test.go +++ b/server/player/shield_test.go @@ -665,6 +665,39 @@ func TestShieldBlockedExplosionAppliesReducedKnockBack(t *testing.T) { } } +func TestShieldBlockedExplosionDuringDamageImmunityAppliesReducedKnockBack(t *testing.T) { + p := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.NewStack(item.Shield{}, 1)) + p.data.Pos = mgl64.Vec3{0, 0, 1} + p.shieldBlockingSince = time.Now().Add(-shieldBlockDelay) + p.immuneUntil = time.Now().Add(time.Second) + p.lastDamage = 10 + + unblocked := newShieldTestPlayer(cube.Rotation{}, item.Stack{}, item.Stack{}) + unblocked.data.Pos = p.data.Pos + unblocked.immuneUntil = p.immuneUntil + unblocked.lastDamage = p.lastDamage + + w := world.New() + defer func() { + _ = w.Close() + }() + explosionPos := mgl64.Vec3{0, 0, 4} + <-w.Exec(func(tx *world.Tx) { + p.tx = tx + unblocked.tx = tx + + conf := block.ExplosionConfig{Size: 1} + p.Explode(explosionPos, 0.2, conf) + unblocked.Explode(explosionPos, 0.2, conf) + }) + if p.Velocity().Len() == 0 { + t.Fatal("expected shield-blocked immune explosion to still apply reduced knockback") + } + if p.Velocity().Len() >= unblocked.Velocity().Len() { + t.Fatalf("expected shield-blocked immune explosion knockback %v to be less than unblocked knockback %v", p.Velocity(), unblocked.Velocity()) + } +} + type cancellingHurtHandler struct { NopHandler } @@ -842,6 +875,16 @@ func TestShouldAttemptShieldBlockBeforeHurtHandler(t *testing.T) { src: entity.ProjectileDamageSource{Projectile: shieldTestEntity{}}, want: true, }, + { + name: "positive explosion damage", + rawDamage: 4, + src: entity.ExplosionDamageSource{ + Origin: mgl64.Vec3{0, 0, 4}, + HasOrigin: true, + BlockableByShield: true, + }, + want: true, + }, { name: "zero damage projectile", src: entity.ProjectileDamageSource{Projectile: shieldTestEntity{}},