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..16071f651 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), true) } } @@ -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,15 @@ 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 { + sourceHandle := entityHandle(source) + spawnTnt(pos, tx, time.Second*4, sourceHandle, true) 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), !c.UnblockableByShield) } // BreakInfo ... @@ -66,9 +67,37 @@ 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, blockableByShield bool) { 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() + tx.AddEntity(conf.TNTWithSource(opts, fuse, source, blockableByShield)) } diff --git a/server/block/tnt_test.go b/server/block/tnt_test.go new file mode 100644 index 000000000..cc8e67d26 --- /dev/null +++ b/server/block/tnt_test.go @@ -0,0 +1,202 @@ +package block + +import ( + "testing" + "time" + + "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 + 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 e.pos } +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, 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{}) +} + +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) + } +} + +func TestTNTSpawnCanCreateShieldBlockableTNTWithoutSource(t *testing.T) { + var blockable bool + 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.EntityHandle, 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") + } +} + +func TestTNTIgniteWithoutSourceIsShieldBlockable(t *testing.T) { + var blockable bool + 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.EntityHandle, 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") + } +} + +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-aware TNT ignition to stay shield-blockable for other players") + } +} + +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) + } +} + +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/entity/damage.go b/server/entity/damage.go index 110378661..77407a18b 100644 --- a/server/entity/damage.go +++ b/server/entity/damage.go @@ -4,6 +4,7 @@ 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" ) type ( @@ -45,7 +46,16 @@ type ( } // 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..824720216 100644 --- a/server/entity/ent.go +++ b/server/entity/ent.go @@ -40,6 +40,21 @@ func (e *Ent) Behaviour() Behaviour { return e.data.Data.(Behaviour) } +// ProjectileOwner returns the entity that owns this Ent, if it has projectile behaviour. +func (e *Ent) ProjectileOwner() *world.EntityHandle { + if projectile, ok := e.Behaviour().(*ProjectileBehaviour); ok { + return projectile.Owner() + } + 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/projectile.go b/server/entity/projectile.go index 1d11f6a2c..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. @@ -159,17 +170,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 +184,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 +273,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) +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) + } 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); vulnerable { + lt.shieldBlocked = false + if _, vulnerable := l.Hurt(dmg, src); lt.shieldBlocked { + 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 +301,25 @@ 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) + } +} + +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..556278fed --- /dev/null +++ b/server/entity/projectile_test.go @@ -0,0 +1,157 @@ +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.Projectile.(*Ent).Behaviour().(interface{ MarkShieldBlocked() }).MarkShieldBlocked() + } + 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 newProjectileShieldTestEnt(pos mgl64.Vec3, behaviour *ProjectileBehaviour) *Ent { + return &Ent{ + handle: world.EntitySpawnOpts{}.New(SnowballType, ProjectileBehaviourConfig{}), + data: &world.EntityData{Pos: pos, Data: behaviour}, + } +} + +func TestProjectileDeflectsAfterShieldBlock(t *testing.T) { + pos := mgl64.Vec3{0, 0, 1} + behaviour := ProjectileBehaviourConfig{Damage: 2}.New() + projectile := newProjectileShieldTestEnt(pos, behaviour) + 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} + behaviour := ProjectileBehaviourConfig{Damage: 0}.New() + projectile := newProjectileShieldTestEnt(pos, behaviour) + 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..469cb69a2 100644 --- a/server/entity/tnt.go +++ b/server/entity/tnt.go @@ -13,24 +13,66 @@ import ( // NewTNT creates a new primed TNT entity. func NewTNT(opts world.EntitySpawnOpts, fuse time.Duration) *world.EntityHandle { - conf := tntConf - conf.ExistenceDuration = fuse + return newTNTWithSourceHandle(opts, fuse, nil, true) +} + +// 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 { 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{ Gravity: 0.04, Drag: 0.02, - Expire: explodeTNT, +} + +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) { - block.ExplosionConfig{ItemDropChance: 1}.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, Source: sourceEntity, UnblockableByShield: !blockableByShield} } // TNTType is a world.EntityType implementation for TNT. @@ -49,11 +91,34 @@ 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) + 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) + } + 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 new file mode 100644 index 000000000..96c85d492 --- /dev/null +++ b/server/entity/tnt_test.go @@ -0,0 +1,129 @@ +package entity + +import ( + "testing" + "time" + + "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 TestTNTExplosionConfigHonoursBlockabilityInput(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 TNT configured as shield-unblockable to remain unblockable") + } + }) +} + +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 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 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 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/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..6dd87fda1 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, shieldBlockingUseHandled bool sleeping bool sleepPos cube.Pos - usingSince time.Time + usingSince, shieldBlockingSince time.Time glideTicks int64 fireTicks int64 @@ -581,23 +581,36 @@ 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 immune := time.Now().Before(p.immuneUntil) if immune { - if damageLeft -= p.lastDamage; damageLeft <= 0 { - return 0, false - } + damageLeft -= p.lastDamage } immunity := time.Second / 2 + damageBeforeHandler := damageLeft + if immune && damageLeft <= 0 { + if shouldAttemptShieldBlockBeforeHurtHandler(dmg, 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 + return 0, false, false + } + if shouldAttemptShieldBlockAfterHurtHandler(dmg, damageLeft, damageBeforeHandler, src) && p.blockDamageWithShield(dmg, src) { + return 0, false, true } p.setAttackImmunity(immunity, totalDamage) @@ -611,11 +624,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 +666,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,7 +705,16 @@ 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{}) + 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 + } p.knockBack(explosionPos, impact, diff[1]/diff.Len()*impact) } @@ -1043,6 +1065,7 @@ func (p *Player) StartSneaking() { p.StopSprinting() } p.sneaking = true + p.updateShieldBlockingState(time.Now()) p.updateState() } @@ -1062,6 +1085,7 @@ func (p *Player) StopSneaking() { return } p.sneaking = false + p.updateShieldBlockingState(time.Now()) p.updateState() } @@ -1380,6 +1404,25 @@ 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.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.shieldBlockingUseHandled = false + p.shieldBlockingInput = false + } + return p.updateShieldBlockingState(time.Now()) } // SetHeldSlot updates the held slot of the player to the slot provided. The @@ -1405,10 +1448,14 @@ func (p *Player) SetHeldSlot(to int) error { } *p.heldSlot = uint32(to) p.usingItem = false + shieldChanged := p.updateHeldItemState() for _, viewer := range p.viewers() { viewer.ViewEntityItems(p) } + if shieldChanged { + p.updateState() + } p.session().SendHeldSlot(to, p, false) return nil } @@ -1453,6 +1500,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(), true) +} + +func (p *Player) hasCooldownAt(item world.Item, now time.Time, cleanExpired bool) bool { if item == nil { return false } @@ -1461,7 +1512,10 @@ func (p *Player) HasCooldown(item world.Item) bool { if !ok { return false } - if time.Now().After(otherTime) { + if now.After(otherTime) { + if !cleanExpired { + return false + } delete(p.cooldowns, name) return false } @@ -1470,12 +1524,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, @@ -1483,15 +1546,29 @@ func (p *Player) SetCooldown(item world.Item, cooldown time.Duration) { // 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()) { + 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() + 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()) @@ -1499,6 +1576,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 @@ -1528,6 +1608,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 @@ -1537,12 +1620,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 } @@ -1577,6 +1666,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 +1745,25 @@ 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 + 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. +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() + } } // UseItemOnBlock uses the item held in the main hand of the player on a block at the position passed. The @@ -2557,11 +2667,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..77254f089 --- /dev/null +++ b/server/player/shield.go @@ -0,0 +1,337 @@ +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/event" + "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 + shieldExplosionKnockBackMultiplier = 0.2 + 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) +} + +// 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, false) { + 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, true) { + 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 + p.shieldBlockingUseHandled = false + return wasPrepared || wasBlocking +} + +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() + 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() + tool, ok := mainHand.Item().(item.Tool) + if !ok || tool.ToolType() != item.TypeAxe { + return 0, false + } + return shieldDisableCooldown, true +} + +func shieldDurabilityDamage(dmg float64) int { + if dmg < shieldDamageThreshold { + return 0 + } + return int(math.Floor(dmg)) + 1 +} + +func shouldAttemptShieldBlockBeforeHurtHandler(_ float64, src world.DamageSource) bool { + switch src.(type) { + case entity.ProjectileDamageSource, entity.ExplosionDamageSource: + return true + default: + return false + } +} + +func shouldAttemptShieldBlockAfterHurtHandler(rawDamage, damageLeft, damageBeforeHandler float64, src world.DamageSource) bool { + if damageLeft > 0 { + return true + } + if damageBeforeHandler > 0 { + return false + } + return isZeroDamageProjectile(rawDamage, src) +} + +func isZeroDamageProjectile(rawDamage float64, src world.DamageSource) bool { + _, 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.canStartShieldBlockingInput(mainHand) { + return false + } + ctx := event.C(p) + p.Handler().HandleItemUse(ctx) + if ctx.Cancelled() { + return false + } + mainHand, _ = p.HeldItems() + if !p.startShieldBlockingInput(mainHand) { + return false + } + p.shieldBlockingUseHandled = true + return true +} + +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 +} + +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) 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 { + 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 { + if marker, ok := s.Projectile.(interface{ MarkShieldBlocked() }); ok { + marker.MarkShieldBlocked() + } + } + 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..9b7905bbe --- /dev/null +++ b/server/player/shield_test.go @@ -0,0 +1,953 @@ +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/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" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl64" +) + +type shieldTestEntity struct { + h *world.EntityHandle + pos mgl64.Vec3 +} + +func (e shieldTestEntity) Close() error { 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{} +} +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 +} + +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) + _ = 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.cooldowns[shieldItemName] = now.Add(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 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 + 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.ShieldBlocking() { + 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 + handler := &countingItemUseHandler{} + p.h = handler + + 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") + } + 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 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.inv.SetItem(1, item.NewStack(item.Arrow{}, 1)) + + 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") + } + if p.ShieldBlocking() { + t.Fatal("expected priority main-hand use to stop shield blocking") + } +} + +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 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 + 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 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 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 + 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 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 + 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) + 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 !shieldBlocked { + t.Fatal("expected zero damage projectile shield block marker to be set") + } +} + +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 + 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 !shieldBlocked { + t.Fatal("expected shield-blocked projectile to be marked even during damage immunity") + } +} + +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 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) + 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 ignored melee hit to deal no vulnerable damage, got damage %v vulnerable %v", dmg, vulnerable) + } + 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) + } +} + +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 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) + 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 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} + 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") + } +} + +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()) + } +} + +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 +} + +func (cancellingHurtHandler) HandleHurt(ctx *Context, _ *float64, _ bool, _ *time.Duration, _ world.DamageSource) { + 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 zeroDamageHurtHandler struct { + NopHandler +} + +func (zeroDamageHurtHandler) HandleHurt(_ *Context, damage *float64, _ bool, _ *time.Duration, _ world.DamageSource) { + *damage = 0 +} + +type cancellingItemUseHandler struct { + NopHandler +} + +func (cancellingItemUseHandler) HandleItemUse(ctx *Context) { + ctx.Cancel() +} + +type countingItemUseHandler struct { + NopHandler + count int +} + +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 + 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 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}}} + + 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) + } + } +} + +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.ShieldBlocking() { + 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 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{}}, + }, + { + name: "positive projectile damage", + rawDamage: 4, + 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{}}, + 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 + 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: "positive raw damage fully reduced", + rawDamage: 4, + damageLeft: 0, + src: entity.AttackDamageSource{Attacker: shieldTestEntity{}}, + }, + { + name: "zero damage projectile", + src: entity.ProjectileDamageSource{Projectile: shieldTestEntity{}}, + want: true, + }, + } { + 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) + } + } +} diff --git a/server/session/entity_metadata.go b/server/session/entity_metadata.go index 427266aac..40eb1ae24 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.(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) } @@ -218,6 +221,10 @@ type breather interface { MaxAirSupply() time.Duration } +type shieldBlocker interface { + ShieldBlocking() bool +} + type immobile interface { Immobile() bool } 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/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 b7d50b753..4b351df20 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)) @@ -489,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 new file mode 100644 index 000000000..b0a1a85c5 --- /dev/null +++ b/server/session/handler_item_stack_request_test.go @@ -0,0 +1,184 @@ +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" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +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) + } +} + +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/session/handler_player_auth_input.go b/server/session/handler_player_auth_input.go index f75fcba2f..1785e34fa 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.InputFlagStartSneaking) || flags.Load(packet.InputFlagSneakCurrentRaw) { + if !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..2b9a51212 --- /dev/null +++ b/server/session/handler_player_auth_input_test.go @@ -0,0 +1,76 @@ +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 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) + flags.Set(packet.InputFlagSneakCurrentRaw) + + if down, ok := shieldBlockingInput(flags, false, false); ok || down { + 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/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..033699ca0 100644 --- a/server/world/entity.go +++ b/server/world/entity.go @@ -360,11 +360,15 @@ 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 nullable handle that caused it to ignite and whether + // 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 Egg func(opts EntitySpawnOpts, owner Entity) *EntityHandle @@ -378,6 +382,11 @@ 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 { + 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..3c75b10ba --- /dev/null +++ b/server/world/entity_test.go @@ -0,0 +1,68 @@ +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 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{}}) + + 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 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{}}) + + 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") + } +} 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 }