diff --git a/README.md b/README.md index 609f9d4..5476557 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Server-side Minecraft Bedrock movement simulation library for Go. -`bedsim` replicates the Bedrock client's movement physics (collisions, stepping, edge-avoidance, liquids, gliding, teleportation) on the server, producing authoritative position and velocity values that can be compared against client-reported state. +`bedsim` replicates the Bedrock client's movement physics on the server, producing authoritative position and velocity values that can be compared against client-reported state. It covers collisions, stepping, edge avoidance, liquids and currents, swimming, bubble columns, Riptide, crawling, gliding, movement enchantments, movement-sensitive blocks, and teleportation. Original code was written by [ethaniccc](https://github.com/ethaniccc) in [oomph](https://github.com/oomph-ac/oomph) and has been ported over into this library. The liquid movement physics were ported from [oomph#145](https://github.com/oomph-ac/oomph/pull/145) by [NopeNotDark](https://github.com/NopeNotDark). @@ -28,6 +28,7 @@ sim := bedsim.Simulator{ Liquids: myLiquidProvider, // second block layer (waterlogged blocks) Effects: myEffectsProvider, // jump boost, levitation, slow falling Inventory: myInventoryProvider, // elytra equipped check + Equipment: myEquipmentProvider, // movement enchantments and leather boots Options: bedsim.SimulationOptions{ Mode: bedsim.SimulationModeAuthoritative, PositionCorrectionThreshold: 0.5, @@ -46,8 +47,9 @@ Set `BlockSemantics` when movement behavior must come from a per-world block registry or custom block data instead of bedsim's Dragonfly-backed defaults. The adapter implements `BlockMovementSemanticsProvider` and returns the full `block.MovementSemantics` bundle: ground friction, any acceleration-only -friction multiplier, climbability, cobweb status, and slime/bed bounce -behavior. Built-in rules live in the +friction multiplier, Soul Speed interaction, climbability, cobweb status, +slime/bed bounce behavior, inside-block movement, and vertical traversal. +Built-in rules live in the `github.com/oomph-ac/bedsim/block` package. Custom ground friction must be finite and positive; an invalid value falls back to the built-in resolver. An invalid acceleration multiplier likewise falls back to the built-in block @@ -73,6 +75,37 @@ should affect water movement. > discovered by type assertion, so a signature typo degrades silently — prefer > the explicit field. +### Optional movement capabilities + +`WorldProvider` is the only required world interface. A world may additionally +implement `BubbleColumnProvider` for upward/downward columns and +`MovementCollisionProvider` for player-dependent collision shapes such as +scaffolding and powder snow. Dynamic collision resolution receives sneak and +descend intent plus leather-boots state. + +`MovementEquipmentProvider` supplies Depth Strider, Soul Speed, Swift Sneak, +Riptide, and leather-boots checks. The legacy `DepthStriderProvider` inventory +extension remains a fallback when the equipment provider reports no Depth +Strider level. `EffectsProvider` also controls Weaving-aware web movement. + +Riptide input flags are not trusted on their own. Set `MovementState.RiptideReady` +for the simulation tick only after validating a charged Riptide-trident release. +Set `MovementState.RiptideCollision` after a server-observed entity collision to +authorize the corresponding stop/reversal; ordinary client stop flags are ignored. +Set `MovementState.RiptideInRain` from trusted weather exposure when rain should +permit launch without direct water contact. + +Pose changes update `MovementState.Size`. Set `StandingHeight`, +`SneakingHeight`, or `CrawlingHeight` when using non-vanilla dimensions; zero +values preserve the current standing height and use vanilla crouch/crawl +heights. + +Movement-sensitive block behavior includes honey blocks, sweet berry bushes, +powder snow, scaffolding, webs (including Weaving), soul sand with Soul +Speed, slime blocks, beds, climbables, fences/walls, and per-block friction. +Dynamic collision behavior still depends on the world adapter returning the +correct shapes for the current block state. + ### Liquid movement When the player's hitbox touches water or lava (and the player is not flying), diff --git a/bedrock_semantics_test.go b/bedrock_semantics_test.go new file mode 100644 index 0000000..34ab1c6 --- /dev/null +++ b/bedrock_semantics_test.go @@ -0,0 +1,66 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" +) + +func TestBlockAirRecognisesOnlyBedrockAirIdentifier(t *testing.T) { + sim := &Simulator{BlockSemantics: encodedBlockSemantics{}} + tests := []struct { + name string + want bool + }{ + {name: "minecraft:air", want: true}, + {name: "minecraft:cave_air", want: false}, + {name: "minecraft:void_air", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sim.blockAir(semanticsNamedBlock{name: tt.name}); got != tt.want { + t.Fatalf("blockAir(%q) = %v, want %v", tt.name, got, tt.want) + } + }) + } +} + +func TestJavaWebIdentifierHasNoBedrockMovementEffect(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:cobweb"}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Vel = mgl32.Vec3{0.1, 0, 0} + state.HasGravity = false + + result := (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).SimulateState(state) + + if want := float32(0.1); math32.Abs(result.Movement.X()-want) > 1e-6 { + t.Fatalf("Java web identifier changed Bedrock movement: got %v, want %v", result.Movement.X(), want) + } +} + +func TestDefaultSneakingHeightMatchesDragonflyBedrockPlayer(t *testing.T) { + state := newBaseState() + + (&Simulator{}).applyInput(state, InputState{StartSneaking: true}) + + if state.Size.Y() != 1.49 { + t.Fatalf("sneaking height = %v, want 1.49", state.Size.Y()) + } +} + +func TestCrawlingCannotStartInOpenAir(t *testing.T) { + state := newBaseState() + + (&Simulator{World: environmentWorld{}}).applyInput(state, InputState{StartCrawling: true}) + + if state.Crawling || state.Size.Y() != 1.8 { + t.Fatalf("open-air crawl was accepted: crawling=%v size=%v", state.Crawling, state.Size) + } +} diff --git a/block/contact.go b/block/contact.go index 6eb6d96..b650331 100644 --- a/block/contact.go +++ b/block/contact.go @@ -5,7 +5,7 @@ import "github.com/df-mc/dragonfly/server/world" type cobweb struct{} func (cobweb) Matches(_ world.Block, name string) bool { - return name == "minecraft:web" || name == "minecraft:cobweb" + return name == "minecraft:web" } func (cobweb) Apply(s *resolution) { diff --git a/block/environment.go b/block/environment.go new file mode 100644 index 0000000..e22cf33 --- /dev/null +++ b/block/environment.go @@ -0,0 +1,20 @@ +package block + +import "github.com/df-mc/dragonfly/server/world" + +type environmentRule struct { + name string + inside InsideMovement + traversal Traversal + honey bool +} + +func (r environmentRule) Matches(_ world.Block, name string) bool { + return name == r.name +} + +func (r environmentRule) Apply(s *resolution) { + s.InsideMovement = r.inside + s.Traversal = r.traversal + s.Honey = r.honey +} diff --git a/block/ground.go b/block/ground.go index 06657bf..3db425a 100644 --- a/block/ground.go +++ b/block/ground.go @@ -16,4 +16,5 @@ func (soulSand) Matches(b world.Block, name string) bool { func (soulSand) Apply(s *resolution) { s.GroundAccelerationFrictionMultiplier *= SoulSandAccelerationFrictionMultiplier + s.SoulSpeedNeutralizesAccelerationFriction = true } diff --git a/block/semantics.go b/block/semantics.go index 575f86c..09fd3e7 100644 --- a/block/semantics.go +++ b/block/semantics.go @@ -21,13 +21,36 @@ const ( BounceBed ) +// InsideMovement identifies velocity changes applied while an entity overlaps +// a block's volume. +type InsideMovement uint8 + +const ( + InsideMovementNone InsideMovement = iota + InsideMovementSweetBerryBush + InsideMovementPowderSnow +) + +// Traversal identifies input-driven vertical movement supported by a block. +type Traversal uint8 + +const ( + TraversalNone Traversal = iota + TraversalScaffolding + TraversalPowderSnow +) + // MovementSemantics is the movement behavior resolved for a block. type MovementSemantics struct { - GroundFriction float32 - GroundAccelerationFrictionMultiplier float32 - Climbable bool - Cobweb bool - Bounce Bounce + GroundFriction float32 + GroundAccelerationFrictionMultiplier float32 + Climbable bool + Cobweb bool + Honey bool + Bounce Bounce + InsideMovement InsideMovement + Traversal Traversal + SoulSpeedNeutralizesAccelerationFriction bool } // rule contributes movement behavior for a block family. @@ -47,6 +70,10 @@ var rules = [...]rule{ cobweb{}, slime{}, bed{}, + environmentRule{name: "minecraft:honey_block", honey: true}, + environmentRule{name: "minecraft:sweet_berry_bush", inside: InsideMovementSweetBerryBush}, + environmentRule{name: "minecraft:powder_snow", inside: InsideMovementPowderSnow, traversal: TraversalPowderSnow}, + environmentRule{name: "minecraft:scaffolding", traversal: TraversalScaffolding}, frictionBlock{name: "minecraft:ice", friction: 0.98}, frictionBlock{name: "minecraft:packed_ice", friction: 0.98}, frictionBlock{name: "minecraft:blue_ice", friction: 0.989}, diff --git a/block_effects.go b/block_effects.go new file mode 100644 index 0000000..daae343 --- /dev/null +++ b/block_effects.go @@ -0,0 +1,118 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl32" + movementblock "github.com/oomph-ac/bedsim/block" +) + +func applyInsideBlockMovement(state *MovementState, movement movementblock.InsideMovement) { + switch movement { + case movementblock.InsideMovementSweetBerryBush: + queueStuckSpeedMultiplier(state, mgl32.Vec3{0.8, 0.75, 0.8}) + case movementblock.InsideMovementPowderSnow: + queueStuckSpeedMultiplier(state, mgl32.Vec3{0.9, 1.5, 0.9}) + } +} + +func queueStuckSpeedMultiplier(state *MovementState, multiplier mgl32.Vec3) { + queued := state.StuckSpeedMultiplier + if queued.LenSqr() <= 1e-7 { + state.StuckSpeedMultiplier = multiplier + return + } + for axis := range 3 { + queued[axis] = min(queued[axis], multiplier[axis]) + } + state.StuckSpeedMultiplier = queued +} + +func applyStuckSpeedMultiplier(state *MovementState) bool { + multiplier := state.StuckSpeedMultiplier + if multiplier.LenSqr() <= 1e-7 { + return false + } + if state.NoClip { + state.StuckSpeedMultiplier = mgl32.Vec3{} + return false + } + state.SetVel(mgl32.Vec3{ + state.Vel.X() * multiplier.X(), + state.Vel.Y() * multiplier.Y(), + state.Vel.Z() * multiplier.Z(), + }) + state.StuckSpeedMultiplier = mgl32.Vec3{} + return true +} + +func applyAscendableMovement(state *MovementState, traversal movementblock.Traversal, leatherBoots bool) { + velocity := state.Vel + switch traversal { + case movementblock.TraversalScaffolding: + if state.PressingDescend { + velocity[1] = -0.15 + } else if state.PressingAscend { + velocity[1] = 0.15 + } + case movementblock.TraversalPowderSnow: + if state.PressingDescend { + velocity[1] = -0.15 + } else if state.PressingAscend && leatherBoots { + velocity[1] = 0.2 + } + } + state.SetVel(velocity) +} + +func (s *Simulator) applyInsideBlockEffects(state *MovementState) { + if s.World == nil { + return + } + bb := state.BoundingBox(s.Options.UseSlideOffset) + min, maxPoint := bb.Min(), bb.Max() + for x := int(math32.Floor(min.X())); x < int(math32.Ceil(maxPoint.X())); x++ { + for y := int(math32.Floor(min.Y())); y < int(math32.Ceil(maxPoint.Y())); y++ { + for z := int(math32.Floor(min.Z())); z < int(math32.Ceil(maxPoint.Z())); z++ { + pos := cube.Pos{x, y, z} + if !bb.IntersectsWith(cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))) { + continue + } + b := s.World.Block(pos) + if s.blockAir(b) { + continue + } + semantics := s.blockMovementSemantics(b) + applyInsideBlockMovement(state, semantics.InsideMovement) + } + } + } + s.applyHoneyWallSlide(state) +} + +func (s *Simulator) applyHoneyWallSlide(state *MovementState) { + if !state.CollideX && !state.CollideZ { + return + } + bb := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{1e-3, 0, 1e-3}) + min, maxPoint := bb.Min(), bb.Max() + for x := int(math32.Floor(min.X())); x < int(math32.Ceil(maxPoint.X())); x++ { + for y := int(math32.Floor(min.Y())); y < int(math32.Ceil(maxPoint.Y())); y++ { + for z := int(math32.Floor(min.Z())); z < int(math32.Ceil(maxPoint.Z())); z++ { + pos := cube.Pos{x, y, z} + if !bb.IntersectsWith(cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))) { + continue + } + if s.blockMovementSemantics(s.World.Block(pos)).Honey { + velocity := state.Vel + velocity[0] *= 0.4 + velocity[1] = max(-0.12, velocity[1]) + velocity[2] *= 0.4 + state.SetVel(velocity) + return + } + } + } + } +} diff --git a/block_effects_test.go b/block_effects_test.go new file mode 100644 index 0000000..79c4ccf --- /dev/null +++ b/block_effects_test.go @@ -0,0 +1,226 @@ +package bedsim + +import ( + "testing" + + "github.com/chewxy/math32" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" + movementblock "github.com/oomph-ac/bedsim/block" +) + +type encodedBlockSemantics struct{} + +func (encodedBlockSemantics) BlockMovementSemantics(b world.Block) movementblock.MovementSemantics { + return movementblock.Resolve(b, BlockName(b)) +} + +type honeyWallWorld struct { + staticWorld + pos cube.Pos +} + +func (w honeyWallWorld) Block(pos cube.Pos) world.Block { + if pos == w.pos { + return semanticsNamedBlock{name: "minecraft:honey_block"} + } + return block.Air{} +} + +func TestStuckMovementMultiplierKeepsStrongestOverlappingEffect(t *testing.T) { + state := newBaseState() + state.Vel = mgl32.Vec3{1, -1, 1} + + applyInsideBlockMovement(state, movementblock.InsideMovementPowderSnow) + applyInsideBlockMovement(state, movementblock.InsideMovementSweetBerryBush) + applyInsideBlockMovement(state, movementblock.InsideMovementSweetBerryBush) + + if want := (mgl32.Vec3{0.8, 0.75, 0.8}); state.StuckSpeedMultiplier != want { + t.Fatalf("expected strongest multiplier %v without compounding, got %v", want, state.StuckSpeedMultiplier) + } + if want := (mgl32.Vec3{1, -1, 1}); state.Vel != want { + t.Fatalf("inside-block scan changed persistent velocity: got %v, want %v", state.Vel, want) + } +} + +func TestStuckMovementMultiplierAppliesOnceAndClearsVelocity(t *testing.T) { + sim := &Simulator{World: mockWorld{}} + state := newBaseState() + state.HasGravity = false + state.Vel = mgl32.Vec3{1, -1, 1} + state.StuckSpeedMultiplier = mgl32.Vec3{0.8, 0.75, 0.8} + + result := sim.SimulateState(state) + + if want := (mgl32.Vec3{0.8, -0.75, 0.8}); result.Movement != want { + t.Fatalf("expected one scaled displacement %v, got %v", want, result.Movement) + } + if state.Vel != (mgl32.Vec3{}) { + t.Fatalf("expected persistent velocity to clear after stuck movement, got %v", state.Vel) + } + if state.StuckSpeedMultiplier != (mgl32.Vec3{}) { + t.Fatalf("expected pending multiplier to clear, got %v", state.StuckSpeedMultiplier) + } +} + +func TestNoClipDiscardsQueuedStuckMovement(t *testing.T) { + state := newBaseState() + state.NoClip = true + state.StuckSpeedMultiplier = mgl32.Vec3{0.8, 0.75, 0.8} + + if applyStuckSpeedMultiplier(state) { + t.Fatal("expected no-clip movement not to consume a multiplier") + } + if state.StuckSpeedMultiplier != (mgl32.Vec3{}) { + t.Fatalf("expected no-clip movement to discard the queued effect, got %v", state.StuckSpeedMultiplier) + } +} + +func TestStuckMovementDoesNotBounce(t *testing.T) { + sim := &Simulator{ + World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 0, -1, 1, 1, 1), + }}, + BlockSemantics: overrideBlockSemantics{semantics: movementblock.MovementSemantics{ + Bounce: movementblock.BounceSlime, + }}, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0, 1, 0} + state.Vel = mgl32.Vec3{0, -1, 0} + state.Gravity = NormalGravity + state.StuckSpeedMultiplier = mgl32.Vec3{0.8, 0.75, 0.8} + + sim.SimulateState(state) + + if state.Vel.Y() > 0 { + t.Fatalf("expected stuck movement to suppress bounce, got y velocity %v", state.Vel.Y()) + } +} + +func TestHoneyBlockReducesJumpPower(t *testing.T) { + sim := &Simulator{ + World: environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{"minecraft:honey_block"}, + }}, + } + state := newBaseState() + state.OnGround = true + state.Jumping = true + state.JumpHeight = DefaultJumpHeight + + if !sim.attemptJump(state, nil) { + t.Fatal("expected jump to be applied") + } + if want := float32(DefaultJumpHeight * 0.6); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected honey jump velocity %v, got %v", want, state.Vel.Y()) + } +} + +func TestHoneyWallSlideAppliesOnSolidSideContact(t *testing.T) { + w := honeyWallWorld{ + staticWorld: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(1, -1, 0, 2, 2, 1), + }}, + pos: cube.Pos{1, 0, 0}, + } + sim := &Simulator{World: w, BlockSemantics: encodedBlockSemantics{}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.7, 0, 0.5} + state.Vel = mgl32.Vec3{1, -0.2, 0.1} + state.HasGravity = false + + sim.SimulateState(state) + + if !state.CollideX { + t.Fatal("expected horizontal collision with honey wall") + } + if state.Vel.Y() != -0.12 { + t.Fatalf("expected honey slide downward cap -0.12, got %v", state.Vel.Y()) + } + if want := float32(0.1 * DefaultAirFriction * 0.4); math32.Abs(state.Vel.Z()-want) > 1e-6 { + t.Fatalf("expected honey slide lateral slowdown %v, got %v", want, state.Vel.Z()) + } +} + +func TestScaffoldingAscendAndDescendSpeeds(t *testing.T) { + state := newBaseState() + state.PressingAscend = true + applyAscendableMovement(state, movementblock.TraversalScaffolding, false) + if state.Vel.Y() != 0.15 { + t.Fatalf("expected scaffolding ascend velocity 0.15, got %v", state.Vel.Y()) + } + + state.PressingAscend = false + state.PressingDescend = true + applyAscendableMovement(state, movementblock.TraversalScaffolding, false) + if state.Vel.Y() != -0.15 { + t.Fatalf("expected scaffolding descend velocity -0.15, got %v", state.Vel.Y()) + } +} + +func TestPowderSnowTraversalRequiresLeatherBoots(t *testing.T) { + state := newBaseState() + state.PressingAscend = true + applyAscendableMovement(state, movementblock.TraversalPowderSnow, false) + if state.Vel.Y() != 0 { + t.Fatalf("expected no powder-snow ascent without leather boots, got %v", state.Vel.Y()) + } + + applyAscendableMovement(state, movementblock.TraversalPowderSnow, true) + if state.Vel.Y() != 0.2 { + t.Fatalf("expected leather-boots powder-snow ascent 0.2, got %v", state.Vel.Y()) + } +} + +func TestHoneyWalkSlowdownMatchesSlime(t *testing.T) { + sim := &Simulator{} + state := newBaseState() + state.OnGround = true + state.Vel = mgl32.Vec3{1, 0.05, 1} + + sim.walkOnBlock(state, semanticsNamedBlock{"minecraft:honey_block"}) + + if want := float32(0.41); math32.Abs(state.Vel.X()-want) > 1e-6 || math32.Abs(state.Vel.Z()-want) > 1e-6 { + t.Fatalf("expected honey walk slowdown %v, got %v", want, state.Vel) + } +} + +func TestSimulationAppliesScaffoldingTraversal(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:scaffolding"}, + }} + sim := &Simulator{World: w, BlockSemantics: encodedBlockSemantics{}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.HasGravity = false + + sim.Simulate(state, InputState{AscendBlock: true}) + + if state.Vel.Y() != 0.15 { + t.Fatalf("expected integrated scaffolding ascent 0.15, got %v", state.Vel.Y()) + } +} + +func TestSimulationDetectsNonSolidWebAndAppliesWeaving(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:web"}, + }} + sim := &Simulator{ + World: w, + BlockSemantics: encodedBlockSemantics{}, + Effects: fixedEffects{EffectWeaving: 0}, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Vel = mgl32.Vec3{0.1, 0, 0} + state.HasGravity = false + + result := sim.SimulateState(state) + + if want := float32(0.05); math32.Abs(result.Movement.X()-want) > 1e-6 { + t.Fatalf("expected Weaving web movement %v, got %v", want, result.Movement.X()) + } +} diff --git a/block_semantics_test.go b/block_semantics_test.go index 6ede0ce..6f1f30c 100644 --- a/block_semantics_test.go +++ b/block_semantics_test.go @@ -13,10 +13,11 @@ import ( func TestSoulBlocksKeepOrdinaryGroundFriction(t *testing.T) { for name, tt := range map[string]struct { - block world.Block - accelScale float32 + block world.Block + accelScale float32 + soulSpeedNeutralizes bool }{ - "soul sand": {block: block.SoulSand{}, accelScale: movementblock.SoulSandAccelerationFrictionMultiplier}, + "soul sand": {block: block.SoulSand{}, accelScale: movementblock.SoulSandAccelerationFrictionMultiplier, soulSpeedNeutralizes: true}, "soul soil": {block: block.SoulSoil{}, accelScale: 1}, } { t.Run(name, func(t *testing.T) { @@ -27,6 +28,9 @@ func TestSoulBlocksKeepOrdinaryGroundFriction(t *testing.T) { if got.GroundAccelerationFrictionMultiplier != tt.accelScale { t.Fatalf("ground acceleration friction multiplier = %.8f, want %.8f", got.GroundAccelerationFrictionMultiplier, tt.accelScale) } + if got.SoulSpeedNeutralizesAccelerationFriction != tt.soulSpeedNeutralizes { + t.Fatalf("soul-speed neutralization = %t, want %t", got.SoulSpeedNeutralizesAccelerationFriction, tt.soulSpeedNeutralizes) + } }) } } @@ -124,6 +128,29 @@ func TestDefaultMovementBlockSemanticsSpecialBlocks(t *testing.T) { } } +func TestEnvironmentMovementSemantics(t *testing.T) { + tests := []struct { + name string + inside movementblock.InsideMovement + traversal movementblock.Traversal + honey bool + }{ + {name: "minecraft:honey_block", honey: true}, + {name: "minecraft:sweet_berry_bush", inside: movementblock.InsideMovementSweetBerryBush}, + {name: "minecraft:powder_snow", inside: movementblock.InsideMovementPowderSnow, traversal: movementblock.TraversalPowderSnow}, + {name: "minecraft:scaffolding", traversal: movementblock.TraversalScaffolding}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := movementblock.Resolve(semanticsNamedBlock{tt.name}, tt.name) + if got.InsideMovement != tt.inside || got.Traversal != tt.traversal || got.Honey != tt.honey { + t.Fatalf("semantics = %+v, want inside=%v traversal=%v honey=%v", got, tt.inside, tt.traversal, tt.honey) + } + }) + } +} + // semanticsNamedBlock is enough to exercise name-based semantics without depending on // a particular Dragonfly block implementation being present in the registry. type semanticsNamedBlock struct{ name string } diff --git a/bubble.go b/bubble.go new file mode 100644 index 0000000..12f9280 --- /dev/null +++ b/bubble.go @@ -0,0 +1,93 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl32" +) + +// BubbleColumnDirection is the direction a bubble column accelerates entities. +type BubbleColumnDirection uint8 + +const ( + BubbleColumnUp BubbleColumnDirection = iota + BubbleColumnDown +) + +// BubbleColumnProvider exposes bubble-column direction separately from liquid +// state so worlds without a concrete bubble-column block type can participate. +type BubbleColumnProvider interface { + BubbleColumn(pos cube.Pos) (BubbleColumnDirection, bool) +} + +func applyBubbleColumn(state *MovementState, direction BubbleColumnDirection, surface bool) { + velocity := state.Vel + switch direction { + case BubbleColumnDown: + cap := float32(-0.3) + if surface { + cap = -0.9 + } + velocity[1] = math32.Max(cap, velocity[1]-0.03) + default: + change, cap := float32(0.06), float32(0.7) + if surface { + change, cap = 0.1, 1.8 + } + velocity[1] = math32.Min(cap, velocity[1]+change) + } + state.SetVel(velocity) +} + +func (s *Simulator) applyBubbleColumns(state *MovementState) { + provider, ok := s.World.(BubbleColumnProvider) + if !ok { + return + } + bb := state.BoundingBox(s.Options.UseSlideOffset) + min, max := bb.Min(), bb.Max() + for x := int(math32.Floor(min.X())); x < int(math32.Ceil(max.X())); x++ { + for y := int(math32.Floor(min.Y())); y < int(math32.Ceil(max.Y())); y++ { + for z := int(math32.Floor(min.Z())); z < int(math32.Ceil(max.Z())); z++ { + pos := cube.Pos{x, y, z} + direction, found := provider.BubbleColumn(pos) + if !found { + continue + } + above := pos.Side(cube.FaceUp) + _, liquidAbove := s.liquidAt(above) + applyBubbleColumn(state, direction, !liquidAbove && s.blockAir(s.blockAtPos(above))) + } + } + } +} + +func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) bool { + if s.Equipment == nil || state.RiptideTicks > 0 || !state.RiptideReady || (!touchingWater && !state.RiptideInRain) { + return false + } + level := s.Equipment.EnchantmentLevel(EnchantmentRiptide) + if level <= 0 || !state.StartingSpinAttack { + return false + } + force := 1.5 + 0.75*float32(level-1) + pitch := state.Rotation.X() * math32.Pi / 180 + yaw := state.Rotation.Z() * math32.Pi / 180 + direction := mgl32.Vec3{-MCSin(yaw) * MCCos(pitch), -MCSin(pitch), MCCos(yaw) * MCCos(pitch)} + if length := direction.Len(); length > 0 { + direction = direction.Mul(force / length) + } + state.SetVel(state.Vel.Add(direction)) + state.RiptideTicks = 20 + state.RiptideCollision = false + state.StartingSpinAttack = false + return true +} + +func stopRiptideOnBlockCollision(state *MovementState) { + if state.RiptideTicks > 0 && (state.CollideX || state.CollideZ) { + state.RiptideTicks = 0 + state.RiptideCollision = false + } +} diff --git a/bubble_test.go b/bubble_test.go new file mode 100644 index 0000000..b057f22 --- /dev/null +++ b/bubble_test.go @@ -0,0 +1,188 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" +) + +func TestBubbleColumnUsesBoarImpulsesAndCaps(t *testing.T) { + tests := []struct { + name string + direction BubbleColumnDirection + surface bool + initial float32 + want float32 + }{ + {name: "submerged up", direction: BubbleColumnUp, initial: 0, want: 0.06}, + {name: "submerged up cap", direction: BubbleColumnUp, initial: 0.69, want: 0.70}, + {name: "surface up", direction: BubbleColumnUp, surface: true, initial: 0, want: 0.10}, + {name: "surface up cap", direction: BubbleColumnUp, surface: true, initial: 1.79, want: 1.80}, + {name: "submerged down", direction: BubbleColumnDown, initial: 0, want: -0.03}, + {name: "submerged down cap", direction: BubbleColumnDown, initial: -0.29, want: -0.30}, + {name: "surface down", direction: BubbleColumnDown, surface: true, initial: 0, want: -0.03}, + {name: "surface down cap", direction: BubbleColumnDown, surface: true, initial: -0.89, want: -0.90}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newBaseState() + state.Vel[1] = tt.initial + applyBubbleColumn(state, tt.direction, tt.surface) + if math32.Abs(state.Vel.Y()-tt.want) > 1e-6 { + t.Fatalf("expected y velocity %v, got %v", tt.want, state.Vel.Y()) + } + }) + } +} + +func TestBubbleColumnSurfaceAcceptsRegistryBackedAir(t *testing.T) { + w := environmentWorld{ + bubbles: map[cube.Pos]BubbleColumnDirection{{0, 0, 0}: BubbleColumnUp}, + blocks: map[cube.Pos]world.Block{{0, 1, 0}: semanticsNamedBlock{name: "minecraft:air"}}, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + + (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).applyBubbleColumns(state) + + if state.Vel.Y() != 0.1 { + t.Fatalf("expected surface bubble impulse above registry-backed air, got %v", state.Vel.Y()) + } +} + +func TestBubbleColumnAppliesForEachOccupiedCell(t *testing.T) { + w := environmentWorld{ + bubbles: map[cube.Pos]BubbleColumnDirection{ + {0, 0, 0}: BubbleColumnUp, + {0, 1, 0}: BubbleColumnUp, + }, + blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: block.Water{Still: true, Depth: 8}, + {0, 1, 0}: block.Water{Still: true, Depth: 8}, + }, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + + (&Simulator{World: w}).applyBubbleColumns(state) + + if want := float32(0.16); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("bubble-column velocity = %v, want per-cell impulses totaling %v", state.Vel.Y(), want) + } +} + +func TestRiptideLaunchesInWaterAndStartsSpinAttack(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Water{Still: true, Depth: 8}}} + sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Gravity = NormalGravity + state.RiptideReady = true + + sim.Simulate(state, InputState{StartSpinAttack: true}) + + if want := float32(1.8); math32.Abs(state.Vel.Z()-want) > 1e-6 { + t.Fatalf("expected riptide velocity %v, got %v", want, state.Vel.Z()) + } + if state.RiptideTicks != 19 { + t.Fatalf("expected 19 riptide ticks after the launch tick, got %d", state.RiptideTicks) + } +} + +func TestRiptideDoesNotLaunchInLava(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Lava{Still: true, Depth: 8}}} + sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Gravity = NormalGravity + state.RiptideReady = true + + sim.Simulate(state, InputState{StartSpinAttack: true}) + + if state.RiptideTicks != 0 { + t.Fatalf("expected lava not to start riptide, got %d ticks", state.RiptideTicks) + } +} + +func TestRiptideDoesNotLaunchWhileFlying(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Water{Still: true, Depth: 8}}} + sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Flying = true + state.Gravity = NormalGravity + state.RiptideReady = true + + sim.Simulate(state, InputState{StartSpinAttack: true}) + + if state.RiptideTicks != 0 { + t.Fatalf("expected flying not to start riptide, got %d ticks", state.RiptideTicks) + } +} + +func TestRiptideRequiresValidatedTridentRelease(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Water{Still: true, Depth: 8}}} + sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Gravity = NormalGravity + + sim.Simulate(state, InputState{StartSpinAttack: true}) + + if state.RiptideTicks != 0 { + t.Fatalf("expected unvalidated start flag not to launch, got %d ticks", state.RiptideTicks) + } +} + +func TestRiptideLaunchesInRainWithoutBlockWater(t *testing.T) { + sim := &Simulator{World: mockWorld{}, Equipment: fixedEquipment{EnchantmentRiptide: 2}} + state := newBaseState() + state.Gravity = NormalGravity + state.RiptideReady = true + state.RiptideInRain = true + + sim.Simulate(state, InputState{StartSpinAttack: true}) + + if state.RiptideTicks != 19 { + t.Fatalf("expected rain-authorized riptide launch, got %d ticks", state.RiptideTicks) + } +} + +func TestRiptideStopsOnNormalMovementWallCollision(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(0.5, -1, -1, 1.5, 2, 1), + }}} + state := newBaseState() + state.Vel = mgl32.Vec3{1, 0, 0} + state.RiptideTicks = 10 + state.HasGravity = false + + sim.SimulateState(state) + + if state.RiptideTicks != 0 { + t.Fatalf("expected wall collision to stop riptide, got %d ticks", state.RiptideTicks) + } +} + +func TestRiptideStopRequiresValidatedEntityCollision(t *testing.T) { + sim := &Simulator{} + state := newBaseState() + state.RiptideTicks = 10 + state.Vel = mgl32.Vec3{1, 0, 0} + + sim.applyInput(state, InputState{StopSpinAttack: true}) + if state.RiptideTicks != 10 || state.Vel.X() != 1 { + t.Fatalf("spoofed stop changed riptide: ticks=%d vel=%v", state.RiptideTicks, state.Vel) + } + + state.RiptideCollision = true + sim.applyInput(state, InputState{StopSpinAttack: true}) + if state.RiptideTicks != 0 || state.Vel.X() != -0.2 { + t.Fatalf("validated stop was not applied: ticks=%d vel=%v", state.RiptideTicks, state.Vel) + } +} diff --git a/constants.go b/constants.go index 4438d41..2c4f698 100644 --- a/constants.go +++ b/constants.go @@ -8,10 +8,10 @@ const ( LevitationGravityMultiplier = float32(0.05) NormalGravity = float32(0.08) SlowFallingGravity = float32(0.01) - StepHeight = float32(0.6) + StepHeight = float32(0.5625) SlideOffsetMultiplier = float32(0.4) SlimeBounceMultiplier = float32(-1) - BedBounceMultiplier = float32(-0.66) + BedBounceMultiplier = float32(-0.75) // This can be validated in Mob::ascendLadder(). ClimbSpeed = float32(0.2) MaxConsumingImpulse = float32(0.1225) @@ -37,4 +37,8 @@ const ( // DefaultSwimWaterGraceTicks bounds retained server-observed water contact. DefaultSwimWaterGraceTicks = 10 + + // EffectWeaving is the Bedrock effect ID for Weaving. Gophertunnel's + // current named effect constants predate the trial effects. + EffectWeaving int32 = 33 ) diff --git a/dynamic_collision_test.go b/dynamic_collision_test.go new file mode 100644 index 0000000..7955ed2 --- /dev/null +++ b/dynamic_collision_test.go @@ -0,0 +1,201 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" +) + +type dynamicCollisionWorld struct { + environmentWorld + lastContext MovementCollisionContext +} + +func (w *dynamicCollisionWorld) GetMovementBBoxes(_ cube.BBox32, context MovementCollisionContext) []cube.BBox32 { + w.lastContext = context + if context.LeatherBoots && !context.Descending && !context.WantDown { + return []cube.BBox32{cube.Box32(0, 0, 0, 1, 1, 1)} + } + return nil +} + +func TestMovementCollisionProviderReceivesPlayerDependentContext(t *testing.T) { + w := &dynamicCollisionWorld{} + sim := &Simulator{World: w, Equipment: leatherEquipment{}} + state := newBaseState() + state.Sneaking = true + state.PressingDescend = false + + boxes := sim.nearbyBBoxes(state, state.BoundingBox(false)) + + if len(boxes) != 1 { + t.Fatalf("expected dynamic powder-snow collision, got %d boxes", len(boxes)) + } + if !w.lastContext.LeatherBoots || !w.lastContext.Sneaking { + t.Fatalf("expected equipment and sneak context, got %+v", w.lastContext) + } +} + +type leatherEquipment struct{} + +func (leatherEquipment) EnchantmentLevel(MovementEnchantment) int { return 0 } +func (leatherEquipment) WearingLeatherBoots() bool { return true } + +type unloadedCollisionWorld struct { + staticWorld + nearbyCalls int +} + +func (w *unloadedCollisionWorld) GetNearbyBBoxes(aabb cube.BBox32) []cube.BBox32 { + w.nearbyCalls++ + return w.staticWorld.GetNearbyBBoxes(aabb) +} + +func TestPoseTransitionsDoNotReadCollisionsFromUnloadedChunks(t *testing.T) { + w := &unloadedCollisionWorld{staticWorld: staticWorld{chunkLoaded: false}} + sim := &Simulator{World: w} + state := newBaseState() + state.Sneaking = true + state.Size[1] = 1.49 + + result := sim.Simulate(state, InputState{StopSneaking: true}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("expected unloaded outcome, got %v", result.Outcome) + } + if w.nearbyCalls != 0 { + t.Fatalf("expected no collision queries in an unloaded chunk, got %d", w.nearbyCalls) + } +} + +func TestCannotUnsneakUnderLowCeiling(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 1.5, -1, 1, 2, 1), + }}} + state := newBaseState() + state.Sneaking = true + state.Size[1] = 1.49 + + sim.applyInput(state, InputState{StopSneaking: true}) + sim.applyInput(state, InputState{}) + + if !state.Sneaking || state.Size.Y() != 1.49 { + t.Fatalf("expected forced sneak pose under ceiling, got sneaking=%v size=%v", state.Sneaking, state.Size) + } +} + +func TestReleasingSneakDownRestoresStandingPose(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true}} + state := newBaseState() + + sim.applyInput(state, InputState{SneakDown: true}) + sim.applyInput(state, InputState{}) + + if state.Sneaking || state.Size.Y() != state.StandingHeight { + t.Fatalf("expected standing pose after releasing sneak, got sneaking=%v size=%v", state.Sneaking, state.Size) + } +} + +func TestCanFitHeightUsesRequestedHeightWhileSwimming(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 1, -1, 1, 2, 1), + }}} + state := newBaseState() + state.Swimming = true + state.SwimWaterGraceTicks = 1 + + if sim.canFitHeight(state, 1.8) { + t.Fatal("expected standing-height fit check to collide despite active swim pose") + } +} + +func TestCannotStopCrawlingUnderLowCeiling(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 0.7, -1, 1, 2, 1), + }}} + state := newBaseState() + state.Crawling = true + state.Size[1] = 0.6 + + sim.applyInput(state, InputState{StopCrawling: true}) + + if !state.Crawling || state.Size.Y() != 0.6 { + t.Fatalf("expected forced crawl pose under ceiling, got crawling=%v size=%v", state.Crawling, state.Size) + } +} + +func TestStopCrawlingWhileSneakingUsesCrouchHeight(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 1.6, -1, 1, 2, 1), + }}} + state := newBaseState() + state.Crawling = true + state.Size[1] = 0.6 + + sim.applyInput(state, InputState{StopCrawling: true, SneakDown: true}) + + if state.Crawling || !state.Sneaking || state.Size.Y() != 1.49 { + t.Fatalf("expected crouch pose under ceiling, got crawling=%v sneaking=%v size=%v", state.Crawling, state.Sneaking, state.Size) + } +} + +func TestStopSwimmingFallsBackToCrawlUnderLowCeiling(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 0.7, -1, 1, 2, 1), + }}} + state := newBaseState() + state.Swimming = true + state.SwimWaterGraceTicks = 1 + + sim.applyInput(state, InputState{StopSwimming: true}) + + if state.Swimming || !state.Crawling || state.Size.Y() != 0.6 { + t.Fatalf("expected crawl fallback after swimming, got swimming=%v crawling=%v size=%v", state.Swimming, state.Crawling, state.Size) + } +} + +func TestStartSwimmingWithoutSwimPosePreservesFittingCrawl(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 0.7, -1, 1, 2, 1), + }}} + state := newBaseState() + state.Crawling = true + state.Size[1] = 0.6 + + sim.applyInput(state, InputState{StartSwimming: true}) + + if !state.Swimming || !state.Crawling || state.Size.Y() != 0.6 { + t.Fatalf("expected crawl pose until swim collapse is observed, got swimming=%v crawling=%v size=%v", state.Swimming, state.Crawling, state.Size) + } +} + +func TestPoseRestoresCustomStandingHeight(t *testing.T) { + state := newBaseState() + state.Size[1] = 2 + sim := &Simulator{} + + sim.applyInput(state, InputState{StartSneaking: true}) + sim.applyInput(state, InputState{StopSneaking: true}) + + if state.Size.Y() != 2 { + t.Fatalf("expected custom standing height 2 to be restored, got %v", state.Size.Y()) + } +} + +func TestSneakingInWaterDescends(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Water{Still: true, Depth: 8}}} + sim := &Simulator{World: w} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Gravity = NormalGravity + + sim.Simulate(state, InputState{SneakDown: true, Sneaking: true}) + + if want := float32(-0.037); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected water descent velocity %v, got %v", want, state.Vel.Y()) + } +} diff --git a/input.go b/input.go index bf59470..e9e65ef 100644 --- a/input.go +++ b/input.go @@ -37,9 +37,18 @@ type InputState struct { StopSwimming bool WantDown bool WantDownSlow bool + StartCrawling bool + StopCrawling bool + DescendBlock bool StopGliding bool StartGliding bool UsingConsumable bool + UsingItem bool + UsingSpear bool + InventoryAction bool + + StartSpinAttack bool + StopSpinAttack bool } diff --git a/interfaces.go b/interfaces.go index 417a561..f9738ce 100644 --- a/interfaces.go +++ b/interfaces.go @@ -20,10 +20,26 @@ type LiquidProvider interface { Liquid(pos cube.Pos) (world.Liquid, bool) } +// MovementCollisionContext contains player-dependent state needed by dynamic +// collision shapes such as scaffolding and powder snow. +type MovementCollisionContext struct { + Position [3]float32 + Sneaking bool + Descending bool + WantDown bool + LeatherBoots bool +} + +// MovementCollisionProvider optionally resolves collision boxes whose shape +// depends on current player input or equipment. +type MovementCollisionProvider interface { + GetMovementBBoxes(aabb cube.BBox32, context MovementCollisionContext) []cube.BBox32 +} + // BlockMovementSemanticsProvider resolves the complete movement behavior for a // block from a custom world registry or block data. GroundFriction and // GroundAccelerationFrictionMultiplier must be finite and positive; invalid -// values fall back to BedSim's built-in semantics. Boolean and bounce values are +// values fall back to BedSim's built-in semantics. Boolean and enum values are // used as returned, including their zero values. type BlockMovementSemanticsProvider interface { BlockMovementSemantics(world.Block) block.MovementSemantics @@ -46,3 +62,20 @@ type InventoryProvider interface { type DepthStriderProvider interface { DepthStriderLevel() int } + +// MovementEnchantment identifies enchantments that directly affect movement. +type MovementEnchantment uint8 + +const ( + EnchantmentDepthStrider MovementEnchantment = iota + EnchantmentSoulSpeed + EnchantmentSwiftSneak + EnchantmentRiptide +) + +// MovementEquipmentProvider exposes equipment and enchantments whose effects +// are part of client movement physics. +type MovementEquipmentProvider interface { + EnchantmentLevel(enchantment MovementEnchantment) int + WearingLeatherBoots() bool +} diff --git a/liquid.go b/liquid.go index 27e5660..78749de 100644 --- a/liquid.go +++ b/liquid.go @@ -50,7 +50,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, // Captured before updateSwimTravel, matching the upstream ordering. jumping := state.EffectiveJumping if water { - if state.WantDown || state.WantDownSlow { + if state.WantDown || state.WantDownSlow || state.PressingDescend { vel := state.Vel vel[1] -= 0.04 state.SetVel(vel) @@ -82,12 +82,17 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, if state.Swimming && state.SwimSpeedMultiplier != 0 { swimSpeedMultiplier = state.SwimSpeedMultiplier } - if inventory, ok := s.Inventory.(DepthStriderProvider); ok { - depthStriderLevel = math32.Min(math32.Max(float32(inventory.DepthStriderLevel()), 0), 3) - if !state.OnGround { - depthStriderLevel *= 0.5 + if s.Equipment != nil { + depthStriderLevel = math32.Min(math32.Max(float32(s.Equipment.EnchantmentLevel(EnchantmentDepthStrider)), 0), 3) + } + if depthStriderLevel == 0 { + if inventory, ok := s.Inventory.(DepthStriderProvider); ok { + depthStriderLevel = math32.Min(math32.Max(float32(inventory.DepthStriderLevel()), 0), 3) } } + if !state.OnGround { + depthStriderLevel *= 0.5 + } depthStriderFraction := depthStriderLevel / 3 if swimSpeedMultiplier > 1 { moveRelativeSpeed *= (0.7 + depthStriderFraction*0.3) * swimSpeedMultiplier @@ -97,11 +102,20 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, } moveRelative(state, moveRelativeSpeed) + stuckMovement := applyStuckSpeedMultiplier(state) oldVel := state.Vel oldOnGround := state.OnGround s.tryCollisions(state, false) + stopRiptideOnBlockCollision(state) + if stuckMovement { + state.SetMov(state.Vel) + state.SetVel(mgl32.Vec3{}) + oldVel = mgl32.Vec3{} + } s.setPostCollisionMotion(state, oldVel, oldOnGround, block.Air{}) - state.SetMov(state.Vel) + if !stuckMovement { + state.SetMov(state.Vel) + } vel := state.Vel if water { @@ -133,7 +147,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, if state.CollideX || state.CollideZ { raised := mgl32.Vec3{vel.X(), vel.Y() + 0.6 + initialY - state.Pos.Y(), vel.Z()} raisedBox := state.BoundingBox(s.Options.UseSlideOffset).Translate(raised) - hasCollision := hasNearbyBBoxes(s.World, raisedBox) + hasCollision := s.hasNearbyBBoxes(state, raisedBox) hasLiquid := s.containsAnyLiquid(raisedBox) s.debugf("liquid exit probe collision=%t liquid=%t box=%v", hasCollision, hasLiquid, raisedBox) if !hasCollision && !hasLiquid { @@ -141,6 +155,8 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, } } state.SetVel(vel) + s.applyBubbleColumns(state) + s.applyInsideBlockEffects(state) state.FallDistance = 0 } @@ -164,7 +180,7 @@ func (s *Simulator) updateSwimTravel(state *MovementState) { rate = 0.085 } - if targetY > 0 && !state.WantDownSlow { + if targetY > 0 && !state.WantDownSlow && !state.PressingDescend { belowPos := posFromVec3(state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset - 1.1})) if _, belowAir := s.liquidMovementBlock(belowPos).(block.Air); belowAir { liquidPos := posFromVec3(state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset - 1.2})) diff --git a/liquid_test.go b/liquid_test.go index 3b75166..62f1d60 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -355,10 +355,33 @@ func TestSwimAmountClampedToUnitRange(t *testing.T) { func TestStartSwimmingClearsSneaking(t *testing.T) { sim := newLiquidSim(newLiquidWorld()) state := newBaseState() + state.Crawling = true sim.applyInput(state, InputState{SneakDown: true, StartSneaking: true, StartSwimming: true}) if state.Sneaking { t.Fatal("StartSwimming must clear Sneaking") } + if state.Size.Y() != state.StandingHeight { + t.Fatalf("StartSwimming must clear the crouched height, got %v", state.Size.Y()) + } + if state.Crawling { + t.Fatal("StartSwimming must clear Crawling") + } +} + +func TestSimulateStateInitializesPoseHeightBeforeSwimming(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Water{Still: true, Depth: 8}}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Swimming = true + state.StandingHeight = 0 + state.SneakingHeight = 0 + state.CrawlingHeight = 0 + + (&Simulator{World: w}).SimulateState(state) + + if state.StandingHeight != 1.8 || state.Size.Y() != 1.8 { + t.Fatalf("expected initialized standing pose, got standing=%v size=%v", state.StandingHeight, state.Size) + } } // StopSwimming wins when both flags arrive in the same tick. @@ -669,6 +692,23 @@ func TestSwimTravelSurfaceClampSkippedWhenWantDownSlow(t *testing.T) { } } +func TestSwimTravelSurfaceClampSkippedWhenPressingDescend(t *testing.T) { + w := newLiquidWorld().fill(cube.Pos{-2, -4, -2}, cube.Pos{2, 0, 2}, waterSource) + sim := newLiquidSim(w) + state := submergedState() + state.Pos = mgl32.Vec3{0.5, 1.5, 0.5} + state.Swimming = true + state.Rotation = mgl32.Vec3{-90, 0, 0} + state.PressingDescend = true + state.Vel = mgl32.Vec3{0, 0.5, 0} + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0) { + t.Fatal("PressingDescend must skip the surface clamp") + } +} + // Depth Strider lowers the horizontal drag coefficient toward 0.546, so // existing momentum decays faster rather than slower. func TestDepthStriderLowersDragCoefficient(t *testing.T) { @@ -787,6 +827,21 @@ func TestInventoryWithoutDepthStriderProvider(t *testing.T) { } } +func TestZeroEquipmentDepthStriderFallsBackToLegacyInventory(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = depthStriderInventory{level: 3} + sim.Equipment = fixedEquipment{} + state := submergedState() + state.Vel = mgl32.Vec3{0.5, 0, 0} + state.OnGround = true + + sim.SimulateState(state) + + if !approxEqual(state.Vel.X(), 0.5*0.54600006) { + t.Fatalf("legacy depth strider X = %v, want level-3 behavior", state.Vel.X()) + } +} + // A dolphin boost raises the swim speed multiplier, which only takes effect // while actually swimming. func TestSwimSpeedMultiplierRequiresSwimming(t *testing.T) { diff --git a/movement.go b/movement.go index 483c348..779ffa6 100644 --- a/movement.go +++ b/movement.go @@ -29,6 +29,16 @@ type MovementState struct { SlideOffset mgl32.Vec2 Impulse mgl32.Vec2 Size mgl32.Vec3 + // StuckSpeedMultiplier is the strongest queued berry-bush or powder-snow + // multiplier. It applies to one displacement, then clears along with + // persistent velocity. + StuckSpeedMultiplier mgl32.Vec3 + // StandingHeight, SneakingHeight, and CrawlingHeight preserve custom entity + // dimensions across pose transitions. Zero values use the current standing + // height and vanilla player pose heights respectively. + StandingHeight float32 + SneakingHeight float32 + CrawlingHeight float32 SupportingBlockPos *cube.Pos @@ -62,6 +72,8 @@ type MovementState struct { ServerSprint, ServerSprintApplied bool Sneaking, PressingSneak bool + PressingAscend bool + PressingDescend bool Jumping, PressingJump bool EffectiveJumping bool @@ -86,6 +98,22 @@ type MovementState struct { GlideBoostTicks int64 HasGravity bool + // SlowFalling reports whether the slow-falling effect is active. Its lower + // gravity only applies while descending. + SlowFalling bool + + Crawling bool + TicksSinceCanSlowdown int + RiptideTicks int + StartingSpinAttack bool + // RiptideReady is a one-tick trusted latch set after validating a charged + // Riptide trident release. RiptideCollision is set after a server-observed + // entity collision and authorizes the matching stop/reversal. + RiptideReady bool + RiptideCollision bool + // RiptideInRain is server-observed weather exposure that permits a + // validated Riptide release without direct block-water contact. + RiptideInRain bool Flying, MayFly, TrustFlyStatus bool JustDisabledFlight bool @@ -102,6 +130,22 @@ type MovementState struct { GameMode int32 } +func (s *MovementState) ensurePoseHeights() { + if s.StandingHeight <= 0 { + if !s.Sneaking && !s.Crawling && s.Size.Y() > 0 { + s.StandingHeight = s.Size.Y() + } else { + s.StandingHeight = 1.8 + } + } + if s.SneakingHeight <= 0 { + s.SneakingHeight = 1.49 + } + if s.CrawlingHeight <= 0 { + s.CrawlingHeight = 0.6 + } +} + func (s *MovementState) SetPos(newPos mgl32.Vec3) { s.LastPos = s.Pos s.Pos = newPos diff --git a/movement_environment_test.go b/movement_environment_test.go new file mode 100644 index 0000000..c71d0f8 --- /dev/null +++ b/movement_environment_test.go @@ -0,0 +1,51 @@ +package bedsim + +import ( + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" +) + +type environmentWorld struct { + bubbles map[cube.Pos]BubbleColumnDirection + solids map[cube.Pos]bool + blocks map[cube.Pos]world.Block +} + +func (w environmentWorld) Block(pos cube.Pos) world.Block { + if b, ok := w.blocks[pos]; ok { + return b + } + if w.solids[pos] { + return block.Stone{} + } + return block.Air{} +} + +func (w environmentWorld) BlockCollisions(pos cube.Pos) []cube.BBox32 { + if w.solids[pos] { + return []cube.BBox32{cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))} + } + return nil +} + +func (w environmentWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { return nil } +func (w environmentWorld) IsChunkLoaded(int32, int32) bool { return true } + +func (w environmentWorld) Liquid(pos cube.Pos) (world.Liquid, bool) { + liquid, ok := w.Block(pos).(world.Liquid) + return liquid, ok +} + +func (w environmentWorld) BubbleColumn(pos cube.Pos) (BubbleColumnDirection, bool) { + direction, ok := w.bubbles[pos] + return direction, ok +} + +type fixedEquipment map[MovementEnchantment]int + +func (e fixedEquipment) EnchantmentLevel(enchantment MovementEnchantment) int { + return e[enchantment] +} + +func (fixedEquipment) WearingLeatherBoots() bool { return false } diff --git a/parity_test.go b/parity_test.go new file mode 100644 index 0000000..31e263e --- /dev/null +++ b/parity_test.go @@ -0,0 +1,119 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + "testing" + + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/go-gl/mathgl/mgl32" + movementblock "github.com/oomph-ac/bedsim/block" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +type fixedEffects map[int32]int32 + +func (e fixedEffects) GetEffect(effectID int32) (int32, bool) { + amplifier, ok := e[effectID] + return amplifier, ok +} + +func TestJumpBoostUsesZeroBasedEffectAmplifier(t *testing.T) { + sim := &Simulator{Effects: fixedEffects{packet.EffectJumpBoost: 0}} + state := newBaseState() + + sim.applyInput(state, InputState{}) + + if want := float32(0.52); math32.Abs(state.JumpHeight-want) > 1e-6 { + t.Fatalf("expected jump boost I height %v, got %v", want, state.JumpHeight) + } +} + +func TestLevitationUsesZeroBasedEffectAmplifier(t *testing.T) { + sim := &Simulator{World: mockWorld{}, Effects: fixedEffects{packet.EffectLevitation: 0}} + state := newBaseState() + state.HasGravity = false + + sim.SimulateState(state) + + if want := float32(0.01); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected levitation I velocity %v, got %v", want, state.Vel.Y()) + } +} + +func TestSlowFallingOnlyChangesGravityWhileDescending(t *testing.T) { + sim := &Simulator{World: mockWorld{}, Effects: fixedEffects{packet.EffectSlowFalling: 0}} + state := newBaseState() + state.Vel = mgl32.Vec3{0, 0.2} + state.Gravity = NormalGravity + state.SlowFalling = true + + sim.SimulateState(state) + + if want := float32((0.2 - NormalGravity) * NormalGravityMultiplier); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected normal gravity while ascending, want %v, got %v", want, state.Vel.Y()) + } +} + +func TestBedrockStepHeight(t *testing.T) { + if want := float32(0.5625); StepHeight != want { + t.Fatalf("expected Bedrock step height %v, got %v", want, StepHeight) + } +} + +func TestBedBounceUsesBedrockRestitutionAndCap(t *testing.T) { + sim := &Simulator{BlockSemantics: overrideBlockSemantics{semantics: movementblock.MovementSemantics{Bounce: movementblock.BounceBed}}} + state := newBaseState() + state.Vel = mgl32.Vec3{0, -2} + + sim.landOnBlock(state, state.Vel, block.Air{}) + + if want := float32(0.75); state.Vel.Y() != want { + t.Fatalf("expected bed bounce %v, got %v", want, state.Vel.Y()) + } +} + +func TestTinyVelocityIsNotDiscardedPrematurely(t *testing.T) { + sim := &Simulator{World: mockWorld{}} + state := newBaseState() + state.HasGravity = false + state.Vel = mgl32.Vec3{1e-7, 0, 0} + + sim.SimulateState(state) + + if state.Vel.X() == 0 { + t.Fatal("expected Bedrock-scale tiny velocity to remain non-zero") + } +} + +func TestSlowFallingChangesGlideGravity(t *testing.T) { + sim := &Simulator{World: mockWorld{}, Inventory: mockInventory{hasElytra: true}} + state := newBaseState() + state.Gliding = true + state.Gravity = NormalGravity + state.SlowFalling = true + state.Vel[1] = -0.01 + + sim.SimulateState(state) + + if want := float32(-0.011025); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected slow-falling glide velocity %v, got %v", want, state.Vel.Y()) + } +} + +func TestSneakEdgeProtectionWhileSlightlyAboveGround(t *testing.T) { + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, -1, -1, 0, 0, 1), + }}} + state := newBaseState() + state.Sneaking = true + state.OnGround = false + state.FallDistance = 0.1 + state.Vel = mgl32.Vec3{0.5, 0, 0} + + sim.avoidEdge(state) + + if state.Vel.X() >= 0.5 { + t.Fatalf("expected edge protection above nearby ground, got velocity %v", state.Vel) + } +} diff --git a/player_features_test.go b/player_features_test.go new file mode 100644 index 0000000..6862864 --- /dev/null +++ b/player_features_test.go @@ -0,0 +1,116 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + "testing" + + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +func TestSoulSpeedSkipsSoulSandSlowdown(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:soul_sand"}, + }} + base := newBaseState() + base.Pos = mgl32.Vec3{0.5, 1, 0.5} + base.OnGround = true + base.HasGravity = false + + without := *base + (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).Simulate(&without, InputState{MoveVector: mgl32.Vec2{0, 1}}) + + with := *base + (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}, Equipment: fixedEquipment{EnchantmentSoulSpeed: 1}}).Simulate(&with, InputState{MoveVector: mgl32.Vec2{0, 1}}) + + if with.Vel.Z() <= without.Vel.Z() { + t.Fatalf("expected Soul Speed to bypass soul-sand slowdown: with=%v without=%v", with.Vel.Z(), without.Vel.Z()) + } +} + +func TestSwiftSneakAppliesAfterTwoSlowdownTicks(t *testing.T) { + sim := &Simulator{Equipment: fixedEquipment{EnchantmentSwiftSneak: 3}} + state := newBaseState() + input := InputState{SneakDown: true, MoveVector: mgl32.Vec2{0, 1}} + + sim.applyInput(state, input) + if want := float32(0.3 * 0.98); math32.Abs(state.Impulse.Y()-want) > 1e-6 { + t.Fatalf("expected first-tick sneak impulse %v, got %v", want, state.Impulse.Y()) + } + sim.applyInput(state, input) + sim.applyInput(state, input) + if want := float32(0.75 * 0.98); math32.Abs(state.Impulse.Y()-want) > 1e-6 { + t.Fatalf("expected Swift Sneak impulse %v after two ticks, got %v", want, state.Impulse.Y()) + } +} + +func TestItemUseAndInventoryActionInputRules(t *testing.T) { + tests := []struct { + name string + input InputState + want float32 + }{ + {name: "using item", input: InputState{UsingItem: true, MoveVector: mgl32.Vec2{0, 1}}, want: MaxConsumingImpulse * 0.98}, + {name: "using spear", input: InputState{UsingItem: true, UsingSpear: true, MoveVector: mgl32.Vec2{0, 1}}, want: 0.98}, + {name: "inventory action", input: InputState{InventoryAction: true, MoveVector: mgl32.Vec2{0, 1}}, want: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newBaseState() + (&Simulator{}).applyInput(state, tt.input) + if math32.Abs(state.Impulse.Y()-tt.want) > 1e-6 { + t.Fatalf("expected impulse %v, got %v", tt.want, state.Impulse.Y()) + } + }) + } +} + +func TestCrawlingUpdatesPoseAndSlowdown(t *testing.T) { + state := newBaseState() + sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ + cube.Box32(-1, 0.7, -1, 1, 2, 1), + }}} + sim.applyInput(state, InputState{StartCrawling: true, MoveVector: mgl32.Vec2{0, 1}}) + + if !state.Crawling || state.Size.Y() != 0.6 { + t.Fatalf("expected crawling pose, got crawling=%v size=%v", state.Crawling, state.Size) + } + if want := float32(0.3 * 0.98); math32.Abs(state.Impulse.Y()-want) > 1e-6 { + t.Fatalf("expected crawling slowdown %v, got %v", want, state.Impulse.Y()) + } +} + +func TestLevitationStopsGliding(t *testing.T) { + sim := &Simulator{ + World: mockWorld{}, + Inventory: mockInventory{hasElytra: true}, + Effects: fixedEffects{packet.EffectLevitation: 0}, + } + state := newBaseState() + state.Gliding = true + state.HasGravity = true + + sim.SimulateState(state) + + if state.Gliding { + t.Fatal("expected levitation to stop gliding") + } + if state.Vel.Y() <= 0 { + t.Fatalf("expected levitation velocity after glide stops, got %v", state.Vel) + } +} + +func TestStoppingGlideDoesNotCancelActiveBoost(t *testing.T) { + state := newBaseState() + state.Gliding = true + state.GlideBoostTicks = 10 + + (&Simulator{}).applyInput(state, InputState{StopGliding: true}) + + if state.GlideBoostTicks != 10 { + t.Fatalf("expected glide boost to keep ticking independently, got %d", state.GlideBoostTicks) + } +} diff --git a/simulation.go b/simulation.go index 5060853..8ad3341 100644 --- a/simulation.go +++ b/simulation.go @@ -51,11 +51,16 @@ func (s *Simulator) debugfIf(cond bool, format string, args ...any) { } func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { + state.ensurePoseHeights() + defer func() { + state.RiptideReady = false + }() teleported := s.attemptTeleport(state) if teleported { // A teleport relocates the player without observing the destination, // so any retained water contact from the origin is void. state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} return SimulationOutcomeTeleport } @@ -72,6 +77,7 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { if s.World != nil && !s.World.IsChunkLoaded(int32(math32.Floor(state.Pos.X()))>>4, int32(math32.Floor(state.Pos.Z()))>>4) { state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} return SimulationOutcomeUnloadedChunk } if state.Immobile || !state.Ready { @@ -79,6 +85,7 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { // Frozen ticks observe nothing, so the budget must not simply pause // and resume later. state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} return SimulationOutcomeImmobileOrNotReady } @@ -117,6 +124,8 @@ func (s *Simulator) resultFromState(state *MovementState, outcome SimulationOutc } func (s *Simulator) applyInput(state *MovementState, input InputState) { + state.ensurePoseHeights() + poseCollisionsAvailable := s.poseCollisionsAvailable(state) state.Client.HorizontalCollision = input.HorizontalCollision state.Client.VerticalCollision = input.VerticalCollision @@ -144,6 +153,8 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.PressingSneak = input.Sneaking state.PressingSprint = input.SprintDown + state.PressingAscend = input.AscendBlock || input.Jumping + state.PressingDescend = input.DescendBlock || input.Sneaking startFlag, stopFlag := input.StartSprinting, input.StopSprinting needsSpeedAdjusted := false @@ -179,20 +190,68 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } } + wantSneak := input.SneakDown || input.StartSneaking + if input.StopSneaking { + wantSneak = false + } if input.StartSneaking { state.Sneaking = true + if !state.Crawling { + state.Size[1] = state.SneakingHeight + } } else if input.StopSneaking { - state.Sneaking = false + if state.Crawling { + state.Sneaking = false + } else if poseCollisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + state.Sneaking = false + state.Size[1] = state.StandingHeight + } else { + state.Sneaking = true + state.Size[1] = state.SneakingHeight + } } else { - state.Sneaking = input.SneakDown + if state.Crawling { + state.Sneaking = false + } else if input.SneakDown { + state.Sneaking = true + state.Size[1] = state.SneakingHeight + } else if state.Sneaking && (!poseCollisionsAvailable || !s.canFitHeight(state, state.StandingHeight)) { + state.Size[1] = state.SneakingHeight + } else { + state.Sneaking = false + state.Size[1] = state.StandingHeight + } + } + if input.StartCrawling { + if poseCollisionsAvailable && !s.canFitHeight(state, state.StandingHeight) { + state.Crawling = true + state.Sneaking = false + state.Size[1] = state.CrawlingHeight + } + } else if input.StopCrawling { + targetHeight := state.StandingHeight + if wantSneak { + targetHeight = state.SneakingHeight + } + if poseCollisionsAvailable && s.canFitHeight(state, targetHeight) { + state.Crawling = false + state.Sneaking = wantSneak + state.Size[1] = targetHeight + } else { + state.Crawling = true + state.Size[1] = state.CrawlingHeight + } } wasSwimming := state.Swimming if input.StopSwimming { state.Swimming = false + s.restorePoseAfterSwimming(state, poseCollisionsAvailable) } else if input.StartSwimming { state.Swimming = true - state.Sneaking = false + if state.SwimPose() || poseCollisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + setSwimmingPoseFlags(state) + } } if wasSwimming { state.SwimAmount = ClampFloat(state.SwimAmount+0.1, 0, 1) @@ -206,17 +265,27 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { // Preserve bedsim's public impulse clamps unless upstream behavior is opted in. maxImpulse := float32(1) if !s.Options.UpstreamImpulseClamping { - if input.UsingConsumable { + if input.UsingConsumable || (input.UsingItem && !input.UsingSpear) { maxImpulse *= MaxConsumingImpulse } - if state.Sneaking { - maxImpulse *= MaxSneakImpulse + if state.Sneaking || state.Crawling || state.Gliding { + state.TicksSinceCanSlowdown++ + sneakMultiplier := MaxSneakImpulse + if state.TicksSinceCanSlowdown > 2 && s.Equipment != nil { + sneakMultiplier += 0.15 * float32(s.Equipment.EnchantmentLevel(EnchantmentSwiftSneak)) + } + maxImpulse *= ClampFloat(sneakMultiplier, 0, 1) + } else { + state.TicksSinceCanSlowdown = 0 } } moveVector := mgl32.Vec2{ ClampFloat(input.MoveVector[0], -maxImpulse, maxImpulse), ClampFloat(input.MoveVector[1], -maxImpulse, maxImpulse), } + if input.InventoryAction { + moveVector = mgl32.Vec2{} + } // Ground jumps are edge-triggered; liquid and ladder ascent may be held. state.Jumping = input.StartJumping @@ -225,7 +294,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.JumpHeight = DefaultJumpHeight if s.Effects != nil { if amp, ok := s.Effects.GetEffect(packet.EffectJumpBoost); ok { - state.JumpHeight += float32(amp) * 0.1 + state.JumpHeight += float32(amp+1) * 0.1 } } @@ -233,19 +302,26 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.JumpDelay = 0 } state.Gravity = NormalGravity + state.SlowFalling = false if s.Effects != nil { if _, ok := s.Effects.GetEffect(packet.EffectSlowFalling); ok { - state.Gravity = SlowFallingGravity + state.SlowFalling = true } } if input.StopGliding { state.Gliding = false - state.GlideBoostTicks = 0 } else if input.StartGliding { state.Gliding = true } + state.StartingSpinAttack = input.StartSpinAttack + if input.StopSpinAttack && state.RiptideTicks > 0 && state.RiptideCollision { + state.RiptideTicks = 0 + state.RiptideCollision = false + state.SetVel(state.Vel.Mul(-0.2)) + } + state.Impulse = moveVector.Mul(0.98) } @@ -288,13 +364,23 @@ func (s *Simulator) tickState(state *MovementState) { if state.JumpDelay > 0 { state.JumpDelay-- } + if state.RiptideTicks > 0 { + state.RiptideTicks-- + if state.RiptideTicks == 0 { + state.RiptideCollision = false + } + } state.JustDisabledFlight = false } func (s *Simulator) simulateMovement(state *MovementState) { - if state.Vel.LenSqr() < 1e-12 { - state.SetVel(mgl32.Vec3{}) + vel := state.Vel + for axis := range 3 { + if math32.Abs(vel[axis]) < 1e-8 { + vel[axis] = 0 + } } + state.SetVel(vel) // Bound retained water evidence before collision and travel inspect it. grace := s.swimWaterGraceTicks() @@ -304,8 +390,15 @@ func (s *Simulator) simulateMovement(state *MovementState) { waterBlocks := s.touchingLiquidBlocks(state, liquidWater) lavaBlocks := s.touchingLiquidBlocks(state, liquidLava) - inWater := len(waterBlocks) != 0 + if inWater && state.Swimming { + state.SwimWaterGraceTicks = grace + setSwimmingPoseFlags(state) + } + if !state.Flying && s.attemptRiptide(state, inWater) { + s.debugf("riptide launch applied: %v", state.Vel) + } + defer func() { if inWater { state.SwimWaterGraceTicks = grace @@ -341,21 +434,36 @@ func (s *Simulator) simulateMovement(state *MovementState) { mSpeed := state.MovementSpeed blockSemantics := s.blockMovementSemantics(blockUnder) blockFriction *= blockSemantics.GroundFriction - accelerationFriction := blockFriction * blockSemantics.GroundAccelerationFrictionMultiplier + accelerationMultiplier := blockSemantics.GroundAccelerationFrictionMultiplier + if s.Equipment != nil && s.Equipment.EnchantmentLevel(EnchantmentSoulSpeed) > 0 && blockSemantics.SoulSpeedNeutralizesAccelerationFriction { + accelerationMultiplier = 1 + } + accelerationFriction := blockFriction * accelerationMultiplier moveRelativeSpeed = mSpeed * (0.16277136 / (accelerationFriction * accelerationFriction * accelerationFriction)) } + if state.Gliding && s.Effects != nil { + if _, levitating := s.Effects.GetEffect(packet.EffectLevitation); levitating { + state.Gliding = false + } + } if state.Gliding { hasElytra := s.Inventory != nil && s.Inventory.HasElytra() if hasElytra && !state.OnGround { state.OnGround = false s.simulateGlide(state) + stuckMovement := applyStuckSpeedMultiplier(state) oldVel := state.Vel oldY := state.Pos.Y() s.tryCollisions(state, false) + stopRiptideOnBlockCollision(state) updateFallDistance(state, oldY) s.debugf("(glide) oldVel=%v, collisions=%v diff=%v", oldVel, state.Vel, state.Vel.Sub(state.Client.Vel)) state.SetMov(state.Vel) + if stuckMovement { + state.SetVel(mgl32.Vec3{}) + } + s.applyInsideBlockEffects(state) return } @@ -369,8 +477,11 @@ func (s *Simulator) simulateMovement(state *MovementState) { moveRelative(state, moveRelativeSpeed) s.debugf("moveRelative force applied (vel=%v)", state.Vel) s.debugfIf(s.attemptJump(state, &clientJumpPrevented), "jump force applied (sprint=%v): %v", state.Sprinting, state.Vel) + insideSemantics := s.blockMovementSemantics(s.blockAtPos(posFromVec3(state.Pos))) + leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() + applyAscendableMovement(state, insideSemantics.Traversal, leatherBoots) - nearClimbable := s.blockMovementSemantics(s.blockAtPos(posFromVec3(state.Pos))).Climbable + nearClimbable := insideSemantics.Climbable if nearClimbable { newVel := state.Vel negClimbSpeed := -ClimbSpeed @@ -391,26 +502,34 @@ func (s *Simulator) simulateMovement(state *MovementState) { if inCobweb { newVel := state.Vel - newVel[0] *= 0.25 - newVel[1] *= 0.05 - newVel[2] *= 0.25 + xz, y := float32(0.25), float32(0.05) + if s.Effects != nil { + if _, weaving := s.Effects.GetEffect(EffectWeaving); weaving { + xz, y = 0.5, 0.25 + } + } + newVel[0] *= xz + newVel[1] *= y + newVel[2] *= xz state.SetVel(newVel) - s.debugf("cobweb force applied (vel=%v)", newVel) + s.debugf("web force applied (vel=%v)", newVel) } + stuckMovement := applyStuckSpeedMultiplier(state) s.avoidEdge(state) oldVel := state.Vel oldOnGround := state.OnGround oldY := state.Pos.Y() s.tryCollisions(state, clientJumpPrevented) + stopRiptideOnBlockCollision(state) updateFallDistance(state, oldY) if state.SupportingBlockPos != nil { blockUnder = s.blockAtPos(*state.SupportingBlockPos) } else { blockUnder = s.blockAtPos(posFromVec3(state.Pos.Sub(mgl32.Vec3{0, 0.2}))) - if _, isAir := blockUnder.(block.Air); isAir { + if s.blockAir(blockUnder) { below := s.blockAtPos(posFromVec3(state.Pos).Side(cube.FaceDown)) if IsWall(below) || IsFence(below) { blockUnder = below @@ -425,6 +544,10 @@ func (s *Simulator) simulateMovement(state *MovementState) { } state.SetMov(state.Vel) + if stuckMovement { + state.SetVel(mgl32.Vec3{}) + oldVel = mgl32.Vec3{} + } s.setPostCollisionMotion(state, oldVel, oldOnGround, blockUnder) if inCobweb { @@ -435,19 +558,20 @@ func (s *Simulator) simulateMovement(state *MovementState) { newVel := state.Vel if s.Effects != nil { if amp, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { - levSpeed := LevitationGravityMultiplier * float32(amp) + levSpeed := LevitationGravityMultiplier * float32(amp+1) newVel[1] += (levSpeed - newVel[1]) * 0.2 } else if state.HasGravity { - newVel[1] -= state.Gravity + newVel[1] -= effectiveGravity(state, newVel) newVel[1] *= NormalGravityMultiplier } } else if state.HasGravity { - newVel[1] -= state.Gravity + newVel[1] -= effectiveGravity(state, newVel) newVel[1] *= NormalGravityMultiplier } newVel[0] *= blockFriction newVel[2] *= blockFriction state.SetVel(newVel) + s.applyInsideBlockEffects(state) } func (s *Simulator) simulationIsReliable(state *MovementState) bool { @@ -468,6 +592,7 @@ func (s *Simulator) resetToClient(state *MovementState) { // A frame we did not simulate proves nothing about water contact, so the // retained evidence is dropped rather than carried across the gap. state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} state.LastPos = state.Client.LastPos state.Pos = state.Client.Pos state.LastVel = state.Client.LastVel @@ -529,7 +654,8 @@ func (s *Simulator) simulateGlide(state *MovementState) { lookHz := pitchCos sqrPitchCos := pitchCos * pitchCos - vel[1] += -0.08 + sqrPitchCos*0.06 + gravity := effectiveGravity(state, vel) + vel[1] += -gravity + sqrPitchCos*(gravity*0.75) if vel[1] < 0 && lookHz > 0 { yAccel := vel[1] * -0.1 * sqrPitchCos vel[1] += yAccel @@ -571,8 +697,8 @@ func (s *Simulator) walkOnBlock(state *MovementState, blockUnder world.Block) { oldVel := state.Vel newVel := state.Vel - switch s.blockMovementSemantics(blockUnder).Bounce { - case movementblock.BounceSlime: + semantics := s.blockMovementSemantics(blockUnder) + if semantics.Bounce == movementblock.BounceSlime || semantics.Honey { yMov := math32.Abs(newVel.Y()) if yMov < 0.1 && !state.PressingSneak { d1 := 0.4 + yMov*0.2 @@ -599,13 +725,20 @@ func (s *Simulator) landOnBlock(state *MovementState, old mgl32.Vec3, blockUnder newVel[1] = 0.0 } case movementblock.BounceBed: - newVel[1] = math32.Min(1.0, BedBounceMultiplier*old.Y()) + newVel[1] = math32.Min(0.75, BedBounceMultiplier*old.Y()) default: newVel[1] = 0 } state.SetVel(newVel) } +func effectiveGravity(state *MovementState, velocity mgl32.Vec3) float32 { + if state.SlowFalling && velocity.Y() < 0 { + return SlowFallingGravity + } + return state.Gravity +} + func (s *Simulator) setPostCollisionMotion(state *MovementState, oldVel mgl32.Vec3, oldOnGround bool, blockUnder world.Block) { if !oldOnGround && state.CollideY { s.landOnBlock(state, oldVel, blockUnder) @@ -670,7 +803,13 @@ func (s *Simulator) attemptJump(state *MovementState, clientJumpPrevented *bool) } newVel := state.Vel - newVel[1] = math32.Max(state.JumpHeight, newVel[1]) + jumpHeight := state.JumpHeight + inBlock := s.blockAtPos(posFromVec3(state.Pos)) + below := s.blockAtPos(posFromVec3(state.Pos.Sub(mgl32.Vec3{0, 0.1}))) + if s.blockMovementSemantics(inBlock).Honey || s.blockMovementSemantics(below).Honey { + jumpHeight *= 0.6 + } + newVel[1] = math32.Max(jumpHeight, newVel[1]) state.JumpDelay = JumpDelayTicks if state.Sprinting { @@ -697,7 +836,7 @@ func (s *Simulator) isJumpBlocked(state *MovementState, jumpVel mgl32.Vec3) bool } useSlideOffset := s.Options.UseSlideOffset collisionBB := state.BoundingBox(useSlideOffset) - bbList := w.GetNearbyBBoxes(collisionBB.Extend(jumpVel)) + bbList := s.nearbyBBoxes(state, collisionBB.Extend(jumpVel)) yVel := mgl32.Vec3{0, jumpVel.Y()} xVel := mgl32.Vec3{jumpVel.X()} @@ -753,7 +892,7 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool var completedStep bool collisionBB := state.BoundingBox(useSlideOffset) currVel := state.Vel - bbList := w.GetNearbyBBoxes(collisionBB.Extend(currVel)) + bbList := s.nearbyBBoxes(state, collisionBB.Extend(currVel)) useOneWayCollisions := state.StuckInCollider penetration := mgl32.Vec3{} @@ -836,10 +975,10 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool newBBListCount := 0 hasStepCollisions := false if s.Options.Debugf != nil { - newBBListCount = len(w.GetNearbyBBoxes(stepBB)) + newBBListCount = len(s.nearbyBBoxes(state, stepBB)) hasStepCollisions = newBBListCount > 0 } else { - hasStepCollisions = hasNearbyBBoxes(w, stepBB) + hasStepCollisions = s.hasNearbyBBoxes(state, stepBB) } stepPos := mgl32.Vec3{ (stepBB.Min().X() + stepBB.Max().X()) * 0.5, @@ -912,7 +1051,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { if w == nil { return } - if !state.Sneaking || !state.OnGround || state.Vel.Y() > 0 { + if !state.Sneaking || !s.isAboveGround(state) || state.Vel.Y() > 0 { s.debugf( "avoidEdge: conditions not met (sneaking=%v onGround=%v yVel=%v)", state.Sneaking, @@ -935,7 +1074,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { xMov, zMov := newVel.X(), newVel.Z() i := 0 - for i = 0; i < maxIter && xMov != 0.0 && !hasNearbyBBoxes(w, bb.Translate(mgl32.Vec3{xMov, -StepHeight * 1.01, 0})); i++ { + for i = 0; i < maxIter && xMov != 0.0 && !s.hasNearbyBBoxes(state, bb.Translate(mgl32.Vec3{xMov, -StepHeight * 1.01, 0})); i++ { if xMov < offset && xMov >= -offset { xMov = 0 } else if xMov > 0 { @@ -948,7 +1087,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { xMov = 0 } - for i = 0; i < maxIter && zMov != 0.0 && !hasNearbyBBoxes(w, bb.Translate(mgl32.Vec3{0, -StepHeight * 1.01, zMov})); i++ { + for i = 0; i < maxIter && zMov != 0.0 && !s.hasNearbyBBoxes(state, bb.Translate(mgl32.Vec3{0, -StepHeight * 1.01, zMov})); i++ { if zMov < offset && zMov >= -offset { zMov = 0 } else if zMov > 0 { @@ -961,7 +1100,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { zMov = 0 } - for i = 0; i < maxIter && xMov != 0.0 && zMov != 0.0 && !hasNearbyBBoxes(w, bb.Translate(mgl32.Vec3{xMov, -StepHeight * 1.01, zMov})); i++ { + for i = 0; i < maxIter && xMov != 0.0 && zMov != 0.0 && !s.hasNearbyBBoxes(state, bb.Translate(mgl32.Vec3{xMov, -StepHeight * 1.01, zMov})); i++ { if xMov < offset && xMov >= -offset { xMov = 0 } else if xMov > 0 { @@ -989,6 +1128,18 @@ func (s *Simulator) avoidEdge(state *MovementState) { s.debugf("(avoidEdge): oldVel=%v newVel=%v", oldVel, newVel) } +func (s *Simulator) isAboveGround(state *MovementState) bool { + if state.OnGround { + return true + } + if state.FallDistance >= 0.6 || s.World == nil { + return false + } + distance := 0.6 - state.FallDistance + bb := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{-0.025, 0, -0.025}) + return s.hasNearbyBBoxes(state, bb.Translate(mgl32.Vec3{0, -distance})) +} + func (s *Simulator) isInsideCobweb(state *MovementState) bool { if s.World == nil { return false @@ -997,7 +1148,7 @@ func (s *Simulator) isInsideCobweb(state *MovementState) bool { bb := state.BoundingBox(s.Options.UseSlideOffset) insideCobweb := false for pos, b := range nearbyBlocks(bb.Grow(1), s.World) { - if _, isAir := b.(block.Air); isAir { + if s.blockAir(b) { continue } if !bb.IntersectsWith(cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))) { @@ -1086,16 +1237,83 @@ func (s *Simulator) blockAtPos(pos cube.Pos) world.Block { return s.World.Block(pos) } +func (s *Simulator) nearbyBBoxes(state *MovementState, aabb cube.BBox32) []cube.BBox32 { + if s.World == nil { + return nil + } + if provider, ok := s.World.(MovementCollisionProvider); ok { + leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() + return provider.GetMovementBBoxes(aabb, MovementCollisionContext{ + Position: [3]float32(state.Pos), + Sneaking: state.Sneaking, + Descending: state.PressingDescend, + WantDown: state.WantDown, + LeatherBoots: leatherBoots, + }) + } + return s.World.GetNearbyBBoxes(aabb) +} + type nearbyBBoxProbe interface { HasNearbyBBoxes(aabb cube.BBox32) bool } -func hasNearbyBBoxes(w WorldProvider, aabb cube.BBox32) bool { - if w == nil { +func (s *Simulator) hasNearbyBBoxes(state *MovementState, aabb cube.BBox32) bool { + if s.World == nil { return false } - if probe, ok := w.(nearbyBBoxProbe); ok { + if _, dynamic := s.World.(MovementCollisionProvider); dynamic { + return len(s.nearbyBBoxes(state, aabb)) > 0 + } + if probe, ok := s.World.(nearbyBBoxProbe); ok { return probe.HasNearbyBBoxes(aabb) } - return len(w.GetNearbyBBoxes(aabb)) > 0 + return len(s.World.GetNearbyBBoxes(aabb)) > 0 +} + +func (s *Simulator) canFitHeight(state *MovementState, height float32) bool { + if s.World == nil { + return true + } + standing := *state + standing.Size[1] = height + standing.Sneaking = false + standing.Swimming = false + standing.SwimWaterGraceTicks = 0 + standing.PressingDescend = false + standing.WantDown = false + return len(s.nearbyBBoxes(&standing, standing.BoundingBox(s.Options.UseSlideOffset))) == 0 +} + +func (s *Simulator) poseCollisionsAvailable(state *MovementState) bool { + if s.World == nil { + return true + } + chunkX := int32(math32.Floor(state.Pos.X())) >> 4 + chunkZ := int32(math32.Floor(state.Pos.Z())) >> 4 + return s.World.IsChunkLoaded(chunkX, chunkZ) +} + +func setSwimmingPoseFlags(state *MovementState) { + state.Sneaking = false + state.Crawling = false + state.Size[1] = state.StandingHeight +} + +func (s *Simulator) restorePoseAfterSwimming(state *MovementState, collisionsAvailable bool) { + if collisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + state.Sneaking = false + state.Crawling = false + state.Size[1] = state.StandingHeight + return + } + if collisionsAvailable && s.canFitHeight(state, state.SneakingHeight) { + state.Sneaking = true + state.Crawling = false + state.Size[1] = state.SneakingHeight + return + } + state.Sneaking = false + state.Crawling = true + state.Size[1] = state.CrawlingHeight } diff --git a/simulator.go b/simulator.go index d531b43..502ac2e 100644 --- a/simulator.go +++ b/simulator.go @@ -3,8 +3,9 @@ package bedsim import ( "github.com/chewxy/math32" + dfblock "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/world" - "github.com/oomph-ac/bedsim/block" + movementblock "github.com/oomph-ac/bedsim/block" ) // SimulationMode defines how strict the simulator should be with client corrections. @@ -71,11 +72,12 @@ type Simulator struct { Liquids LiquidProvider Effects EffectsProvider Inventory InventoryProvider + Equipment MovementEquipmentProvider Options SimulationOptions } -func (DefaultBlockSemantics) BlockMovementSemantics(b world.Block) block.MovementSemantics { - return block.Resolve(b, BlockName(b)) +func (DefaultBlockSemantics) BlockMovementSemantics(b world.Block) movementblock.MovementSemantics { + return movementblock.Resolve(b, BlockName(b)) } // swimWaterGraceTicks resolves the configured grace window: zero means the @@ -99,14 +101,14 @@ func validGroundAccelerationFrictionMultiplier(multiplier float32) bool { return multiplier > 0 && !math32.IsInf(multiplier, 1) } -func (s *Simulator) blockMovementSemantics(b world.Block) block.MovementSemantics { +func (s *Simulator) blockMovementSemantics(b world.Block) movementblock.MovementSemantics { if s.BlockSemantics != nil { semantics := s.BlockSemantics.BlockMovementSemantics(b) - var fallback block.MovementSemantics + var fallback movementblock.MovementSemantics resolvedFallback := false - resolveFallback := func() block.MovementSemantics { + resolveFallback := func() movementblock.MovementSemantics { if !resolvedFallback { - fallback = block.Resolve(b, BlockName(b)) + fallback = movementblock.Resolve(b, BlockName(b)) resolvedFallback = true } return fallback @@ -119,5 +121,12 @@ func (s *Simulator) blockMovementSemantics(b world.Block) block.MovementSemantic } return semantics } - return block.Resolve(b, BlockName(b)) + return movementblock.Resolve(b, BlockName(b)) +} + +func (s *Simulator) blockAir(b world.Block) bool { + if _, ok := b.(dfblock.Air); ok { + return true + } + return BlockName(b) == "minecraft:air" } diff --git a/simulator_test.go b/simulator_test.go index 08ee041..c27e148 100644 --- a/simulator_test.go +++ b/simulator_test.go @@ -450,6 +450,39 @@ func TestSimulateStateOutcomeImmobileOrNotReady(t *testing.T) { } } +func TestEarlyExitClearsQueuedStuckMovement(t *testing.T) { + tests := []struct { + name string + setup func(*MovementState) + world WorldProvider + }{ + {name: "unloaded chunk", world: staticWorld{chunkLoaded: false}}, + {name: "immobile", world: mockWorld{}, setup: func(state *MovementState) { state.Immobile = true }}, + {name: "unreliable", world: mockWorld{}, setup: func(state *MovementState) { state.GameMode = packet.GameTypeCreative }}, + {name: "teleport", world: mockWorld{}, setup: func(state *MovementState) { + state.TeleportCompletionTicks = 1 + state.PendingTeleports = 1 + state.TeleportPos = mgl32.Vec3{10, 20, 30} + }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newBaseState() + state.StuckSpeedMultiplier = mgl32.Vec3{0.8, 0.75, 0.8} + if tt.setup != nil { + tt.setup(state) + } + + (&Simulator{World: tt.world}).SimulateState(state) + + if state.StuckSpeedMultiplier != (mgl32.Vec3{}) { + t.Fatalf("expected queued stuck movement to clear, got %v", state.StuckSpeedMultiplier) + } + }) + } +} + func TestSimulateStateSkipsGravityWhenDisabled(t *testing.T) { sim := &Simulator{ World: mockWorld{}, @@ -572,7 +605,7 @@ func TestSimulateStateDebugTraceJumpBlocked(t *testing.T) { func TestStepUpTiebreaker(t *testing.T) { // Geometry: ground at Y=0, a 0.5-high slab at X=1 (X=1..2, Y=0..0.5). // The player stands on the ground at X≈0.5, walks in +X toward the slab. - // The step-up (0.5 blocks) is within StepHeight (0.6). + // The step-up (0.5 blocks) is within StepHeight (0.5625). slabBox := cube.Box32(1, 0, -1, 2, 0.5, 2) groundBox := cube.Box32(-1, -1, -1, 1, 0, 2)