diff --git a/README.md b/README.md index 52e8c1e..f868e70 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,10 @@ Server-side Minecraft Bedrock movement simulation library for Go. -`bedsim` replicates the Bedrock client's movement physics (collisions, stepping, edge-avoidance, 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 (collisions, stepping, edge-avoidance, liquids, gliding, teleportation) on the server, producing authoritative position and velocity values that can be compared against client-reported state. 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). ## Installation @@ -50,12 +51,14 @@ Implement provider adapters to bridge your world and player systems: sim := bedsim.Simulator{ World: myWorldProvider, // block lookups, collisions, chunk-loaded checks BlockSemantics: myBlockSemantics, // optional: per-world names, friction, climbability + Liquids: myLiquidProvider, // second block layer (waterlogged blocks) Effects: myEffectsProvider, // jump boost, levitation, slow falling Inventory: myInventoryProvider, // elytra equipped check Options: bedsim.SimulationOptions{ Mode: bedsim.SimulationModeAuthoritative, PositionCorrectionThreshold: 0.5, VelocityCorrectionThreshold: 0.5, + RequireLiquidLayer: true, }, } @@ -70,6 +73,102 @@ registry or custom block data instead of bedsim's Dragonfly-backed defaults. Custom friction values must be finite and positive; invalid values fall back to Dragonfly defaults. +Implement `DepthStriderProvider` on the inventory adapter when Depth Strider +should affect water movement. + +> **Set `Liquids` if you simulate authoritatively.** Bedrock stores waterlogged +> blocks as a liquid in the *second* block layer. Without a `LiquidProvider`, +> bedsim only sees liquids returned by `WorldProvider.Block`, so waterlogged +> blocks look dry and the player is simulated with air physics inside them. +> Check `sim.HasLiquidLayer()` at startup, or set +> `SimulationOptions.RequireLiquidLayer` to make the simulator return +> `SimulationOutcomeUnreliable` rather than silently mis-simulate. +> +> If `Liquids` is nil and `World` itself implements `LiquidProvider`, that is +> used instead. This keeps pre-existing integrations working, but it is +> discovered by type assertion, so a signature typo degrades silently — prefer +> the explicit field. + +### Liquid movement + +When the player's hitbox touches water or lava (and the player is not flying), +bedsim runs liquid travel instead of the normal ground/air step. Water takes +priority when both are touched, and a player whose client reports the swimming +pose keeps water travel even after leaving the water blocks. + +Liquid travel covers: per-liquid acceleration and drag, liquid gravity, +levitation, Depth Strider, the dolphin-boost swim multiplier, pitch-steered +swim travel with a surface clamp, flow from depth gradients including falling +liquid and solid faces, and an exit probe that boosts the player over a ledge. + +Callers feed these `InputState` fields from the client's input flags: + +| Field | Client input flag | +| --- | --- | +| `StartSwimming` / `StopSwimming` | `StartSwimming` / `StopSwimming` | +| `WantDown` / `WantDownSlow` | `WantDown` / `WantDownSlow` | +| `AutoJumpingInWater` | `AutoJumpingInWater` | +| `AscendBlock` | `AscendBlock` | + +`Jumping` remains edge-triggered from `StartJumping` and arms a ground jump. +`EffectiveJumping` is derived from the held jump key, `AutoJumpingInWater`, and +`AscendBlock`, and drives liquid ascent and ladder climbing. + +These `MovementState` fields tune liquid physics; each is optional and falls +back to a documented default when left at its zero value: + +- `UnderwaterMovementSpeed` (default `0.02`) +- `LavaMovementSpeed` (default `0.02`) +- `SwimSpeedMultiplier` (default `1`; a dolphin boost sets it to `2`) + +Set `DolphinBoostTicks` when the client receives a dolphin boost. `Simulate` +counts it down and restores `SwimSpeedMultiplier` to its default on expiry; +callers using `SimulateState` must manage that lifecycle themselves. + +While the swim pose is active the bounding box collapses to a width-sized cube, +matching the client's swim pose. This affects collisions and liquid detection, +not just liquid travel. The pose requires both `Swimming` and recent +server-observed water contact — see `MovementState.SwimPose` and the divergence +note below. + +#### Divergences from the upstream source + +Two behaviors intentionally differ from oomph PR #145. + +**Swim water-travel grace (security hardening).** Upstream keys water travel on +`touchingWater || Swimming`, trusting the client's swimming flag because the +surrounding anticheat validates it. A standalone simulator cannot. Left as-is, +a client that latches the flag gets two things it should not: *zero gravity* +forever in open air with no correction raised, and a server-side hitbox +shrunk from 1.8 to 0.6, letting it fit through gaps a standing player cannot. + +bedsim gates **both** the water-travel branch and the swim pose on recent +server-observed water contact, bounded by +`SimulationOptions.SwimWaterGraceTicks` (default `DefaultSwimWaterGraceTicks`, +10). The budget refills on every tick the hitbox actually touches water, is +clamped to the configured bound before anything reads it, and is cleared on any +frame that was not simulated — unreliable, unloaded chunk, immobile, or +teleport. A negative value requires real water contact on every tick. Lava the +player is actually standing in takes priority over a retained water grace. + +Because the budget is applied at the start of a tick and decremented at the end, +it is constant for the whole tick, so collision, liquid detection and exit +probing always agree on one hitbox. The cost is that entering water adopts the +swim pose one tick later than upstream, erring toward the larger box. + +Players genuinely in water are unaffected. Two residual limits are worth +knowing: a client that reaches real water once every `SwimWaterGraceTicks` ticks +sustains water travel at up to a 10:1 duty cycle, so the guard bounds hovering +to the neighbourhood of actual water rather than eliminating it; and the pose +lag above is a deliberate one-tick divergence from upstream. Lower +`SwimWaterGraceTicks` to tighten both. + +**Impulse clamps.** Upstream removed `MaxSneakImpulse` and `MaxConsumingImpulse` +in this PR, clamping the move vector to `[-1, 1]` instead. bedsim keeps both by +default, because they are public API affecting all movement and removing them +would be a breaking change outside liquid scope. Set +`SimulationOptions.UpstreamImpulseClamping` to opt into upstream's behavior. + ### Simulation modes - `Simulate` — applies client input, runs physics, advances tick counters, and returns the result. Use this when bedsim owns the full tick lifecycle. diff --git a/bbox.go b/bbox.go index 97ca97b..2ccc74d 100644 --- a/bbox.go +++ b/bbox.go @@ -5,11 +5,20 @@ import ( "github.com/go-gl/mathgl/mgl64" ) +// SwimPose reports whether recent server-observed water contact permits the +// client-requested collapsed hitbox. +func (s *MovementState) SwimPose() bool { + return s.Swimming && s.SwimWaterGraceTicks > 0 +} + // BoundingBox returns the entity bounding box translated to the current position. func (s *MovementState) BoundingBox(useSlideOffset bool) cube.BBox { scale := s.Size[2] width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale + if s.SwimPose() { + height = s.Size[0] * scale + } yOffset := 0.0 if useSlideOffset { yOffset = s.SlideOffset.Y() @@ -30,6 +39,9 @@ func (s *MovementState) ClientBoundingBox(useSlideOffset bool) cube.BBox { scale := s.Size[2] width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale + if s.SwimPose() { + height = s.Size[0] * scale + } yOffset := 0.0 if useSlideOffset { yOffset = s.SlideOffset.Y() diff --git a/constants.go b/constants.go index 6c44dcf..19b9761 100644 --- a/constants.go +++ b/constants.go @@ -13,10 +13,16 @@ const ( SlimeBounceMultiplier = -1.0 BedBounceMultiplier = -0.66 // This can be validated in Mob::ascendLadder(). - ClimbSpeed = 0.2 - MaxConsumingImpulse = 0.1225 - MaxSneakImpulse = 0.3 - MaxNormalizedImpulse = 0.70710678118 // 1/sqrt(2) + ClimbSpeed = 0.2 + MaxConsumingImpulse = 0.1225 + MaxSneakImpulse = 0.3 + // Deprecated: MaxNormalizedImpulse is unused by the simulator. The + // diagonal-impulse normalization it was intended for is disabled upstream + // as well. It is retained only for API compatibility. + MaxNormalizedImpulse = 0.70710678118 // 1/sqrt(2) + DefaultUnderwaterMovementSpeed = 0.02 + DefaultLavaMovementSpeed = 0.02 + DefaultSwimSpeedMultiplier = 1.0 DefaultPlayerHeightOffset = 1.62 SneakingPlayerHeightOffset = 1.27 @@ -28,4 +34,7 @@ const ( JumpDelayTicks = 10 GlideBoostTicks = 20 + + // DefaultSwimWaterGraceTicks bounds retained server-observed water contact. + DefaultSwimWaterGraceTicks = 10 ) diff --git a/input.go b/input.go index d41f3ad..8d20f53 100644 --- a/input.go +++ b/input.go @@ -28,8 +28,15 @@ type InputState struct { SneakDown bool Sneaking bool - StartJumping bool - Jumping bool + StartJumping bool + Jumping bool + AutoJumpingInWater bool + AscendBlock bool + + StartSwimming bool + StopSwimming bool + WantDown bool + WantDownSlow bool StopGliding bool StartGliding bool diff --git a/interfaces.go b/interfaces.go index 01c5bc9..20e5d2e 100644 --- a/interfaces.go +++ b/interfaces.go @@ -13,6 +13,11 @@ type WorldProvider interface { IsChunkLoaded(chunkX, chunkZ int32) bool } +// LiquidProvider returns liquids from either block layer at a position. +type LiquidProvider interface { + Liquid(pos cube.Pos) (world.Liquid, bool) +} + // BlockSemanticsProvider resolves movement-relevant block behavior. Implement // this when names, friction, or climbability come from a per-world registry or // custom block data instead of Dragonfly's default block types. @@ -34,3 +39,8 @@ type EffectsProvider interface { type InventoryProvider interface { HasElytra() bool } + +// DepthStriderProvider exposes the equipped Depth Strider level. +type DepthStriderProvider interface { + DepthStriderLevel() int +} diff --git a/liquid.go b/liquid.go new file mode 100644 index 0000000..82d5b5c --- /dev/null +++ b/liquid.go @@ -0,0 +1,372 @@ +package bedsim + +import ( + "math" + + "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/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// Liquid movement follows oomph PR #145 at 0bcbb8b. bedsim retains float64, +// provider-based liquid lookup and its legacy impulse clamps. It also requires +// recent server-observed water contact before trusting the client swim flag. +// See README.md for complete compatibility and security notes. + +// liquidKind identifies the liquid family being simulated. +type liquidKind uint8 + +const ( + liquidWater liquidKind = iota + liquidLava +) + +func (k liquidKind) typeName() string { + if k == liquidLava { + return "lava" + } + return "water" +} + +func (k liquidKind) matches(liquid world.Liquid) bool { + return liquid.LiquidType() == k.typeName() +} + +var liquidFaces = [...]struct { + delta cube.Pos + vec mgl64.Vec3 +}{ + {cube.Pos{-1, 0, 0}, mgl64.Vec3{-1, 0, 0}}, + {cube.Pos{1, 0, 0}, mgl64.Vec3{1, 0, 0}}, + {cube.Pos{0, 0, -1}, mgl64.Vec3{0, 0, -1}}, + {cube.Pos{0, 0, 1}, mgl64.Vec3{0, 0, 1}}, +} + +func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, touchingLiquid bool) { + initialY := state.Pos.Y() + water := kind == liquidWater + // Captured before updateSwimTravel, matching the upstream ordering. + jumping := state.EffectiveJumping + if water { + if state.WantDown || state.WantDownSlow { + vel := state.Vel + vel[1] -= 0.04 + state.SetVel(vel) + } + s.updateSwimTravel(state) + } + + if jumping { + vel := state.Vel + if state.SwimAmount > 0 && state.SwimAmount < 1 || water && state.Swimming && !touchingLiquid { + vel[1] = 0 + } else { + vel[1] += 0.04 + } + state.SetVel(vel) + } + + moveRelativeSpeed := state.LavaMovementSpeed + if moveRelativeSpeed == 0 { + moveRelativeSpeed = DefaultLavaMovementSpeed + } + depthStriderLevel := 0.0 + swimSpeedMultiplier := DefaultSwimSpeedMultiplier + if water { + moveRelativeSpeed = state.UnderwaterMovementSpeed + if moveRelativeSpeed == 0 { + moveRelativeSpeed = DefaultUnderwaterMovementSpeed + } + if state.Swimming && state.SwimSpeedMultiplier != 0 { + swimSpeedMultiplier = state.SwimSpeedMultiplier + } + if inventory, ok := s.Inventory.(DepthStriderProvider); ok { + depthStriderLevel = math.Min(math.Max(float64(inventory.DepthStriderLevel()), 0), 3) + if !state.OnGround { + depthStriderLevel *= 0.5 + } + } + depthStriderFraction := depthStriderLevel / 3 + if swimSpeedMultiplier > 1 { + moveRelativeSpeed *= (0.7 + depthStriderFraction*0.3) * swimSpeedMultiplier + } else { + moveRelativeSpeed += (state.MovementSpeed - moveRelativeSpeed) * depthStriderFraction + } + } + + moveRelative(state, moveRelativeSpeed) + oldVel := state.Vel + oldOnGround := state.OnGround + s.tryCollisions(state, false) + s.setPostCollisionMotion(state, oldVel, oldOnGround, block.Air{}) + state.SetMov(state.Vel) + + vel := state.Vel + if water { + drag := 0.8 + if state.Sprinting { + drag = 0.9 + } + if depthStriderLevel > 0 && swimSpeedMultiplier <= 1 { + drag += (0.54600006 - drag) * (depthStriderLevel / 3) + } + vel[0] *= drag + vel[1] *= 0.8 + vel[2] *= drag + } else { + vel = vel.Mul(0.5) + } + + if s.Effects != nil { + if amplifier, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { + target := LevitationGravityMultiplier * float64(amplifier+1) + vel[1] += (target - vel[1]) * 0.2 + } else if state.HasGravity { + vel[1] -= liquidGravity(state.Swimming, water) + } + } else if state.HasGravity { + vel[1] -= liquidGravity(state.Swimming, water) + } + + if state.CollideX || state.CollideZ { + raised := mgl64.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) + hasLiquid := s.containsAnyLiquid(raisedBox) + s.debugf("liquid exit probe collision=%t liquid=%t box=%v", hasCollision, hasLiquid, raisedBox) + if !hasCollision && !hasLiquid { + vel[1] = 0.3 + } + } + state.SetVel(vel) + state.FallDistance = 0 +} + +func liquidGravity(swimming, water bool) float64 { + if !water { + return 0.02 + } + if swimming { + return 0 + } + return 0.005 +} + +func (s *Simulator) updateSwimTravel(state *MovementState) { + if !state.Swimming || state.EffectiveJumping { + return + } + targetY := -MCSin(state.Rotation.X() * math.Pi / 180) + rate := 0.06 + if targetY < -0.2 { + rate = 0.085 + } + + if targetY > 0 && !state.WantDownSlow { + belowPos := cube.PosFromVec3(state.Pos.Add(mgl64.Vec3{0, DefaultPlayerHeightOffset - 1.1})) + if _, belowAir := s.liquidMovementBlock(belowPos).(block.Air); belowAir { + liquidPos := cube.PosFromVec3(state.Pos.Add(mgl64.Vec3{0, DefaultPlayerHeightOffset - 1.2})) + if _, liquid := s.liquidAt(liquidPos); !liquid { + vel := state.Vel + vel[1] = 0 + state.SetVel(vel) + return + } + } + } + vel := state.Vel + vel[1] += (targetY - vel[1]) * rate + state.SetVel(vel) +} + +func (s *Simulator) touchingLiquidBlocks(state *MovementState, kind liquidKind) []cube.Pos { + box := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl64.Vec3{1e-4, 0, 1e-4}) + offset := mgl64.Vec3{0.001, 0.401, 0.001} + if kind == liquidLava { + offset = mgl64.Vec3{0.1, 0.4, 0.1} + } + box = shrinkLiquidBox(box, offset) + + min, max := box.Min(), box.Max() + minX, minY, minZ := int(math.Floor(min.X())), int(math.Floor(min.Y())), int(math.Floor(min.Z())) + maxX, maxY, maxZ := int(math.Floor(max.X()+1)), int(math.Floor(max.Y()+1)), int(math.Floor(max.Z()+1)) + positions := make([]cube.Pos, 0, 4) + for x := minX; x < maxX; x++ { + for y := minY; y < maxY; y++ { + for z := minZ; z < maxZ; z++ { + pos := cube.Pos{x, y, z} + liquid, ok := s.liquidAt(pos) + if !ok || !kind.matches(liquid) { + continue + } + if s.Options.Debugf != nil { + height := liquidHeight(liquid) + surface := float64(pos[1]) + height + s.debugf( + "liquid block type=%s pos=%v depth=%d falling=%t height=%.6f surface=%.6f boxY=[%.6f %.6f] immersion=%.6f", + liquid.LiquidType(), pos, liquid.LiquidDepth(), liquid.LiquidFalling(), height, surface, + box.Min().Y(), box.Max().Y(), surface-box.Min().Y(), + ) + } + positions = append(positions, pos) + } + } + } + return positions +} + +func shrinkLiquidBox(box cube.BBox, offset mgl64.Vec3) cube.BBox { + min, max := box.Min().Add(offset), box.Max().Sub(offset) + originalMin, originalMax := box.Min(), box.Max() + for axis := range 3 { + if min[axis] > max[axis] { + mid := (originalMin[axis] + originalMax[axis]) * 0.5 + min[axis], max[axis] = mid, mid + } + } + return cube.Box(min.X(), min.Y(), min.Z(), max.X(), max.Y(), max.Z()) +} + +func (s *Simulator) liquidMovementBlock(pos cube.Pos) world.Block { + if liquid, ok := s.liquidAt(pos); ok { + return liquid + } + return s.blockAtPos(pos) +} + +// blockCollisions returns the collision boxes at pos, treating an absent world +// as empty space so liquid flow never dereferences a nil provider. +func (s *Simulator) blockCollisions(pos cube.Pos) []cube.BBox { + if s.World == nil { + return nil + } + return s.World.BlockCollisions(pos) +} + +// liquidLayer resolves the configured second-layer liquid source. The explicit +// Simulator.Liquids field wins; a World that itself implements LiquidProvider is +// accepted for compatibility with integrations written before the field existed. +func (s *Simulator) liquidLayer() (LiquidProvider, bool) { + if s.Liquids != nil { + return s.Liquids, true + } + if provider, ok := s.World.(LiquidProvider); ok { + return provider, true + } + return nil, false +} + +// HasLiquidLayer reports whether this simulator can see liquids in the second +// block layer, which is what makes waterlogged blocks visible to movement. +// Callers running authoritatively should assert this at startup, or set +// SimulationOptions.RequireLiquidLayer to fail closed instead. +func (s *Simulator) HasLiquidLayer() bool { + _, ok := s.liquidLayer() + return ok +} + +func (s *Simulator) liquidAt(pos cube.Pos) (world.Liquid, bool) { + if provider, ok := s.liquidLayer(); ok { + if liquid, found := provider.Liquid(pos); found { + return liquid, true + } + } + liquid, ok := s.blockAtPos(pos).(world.Liquid) + return liquid, ok +} + +func liquidHeight(liquid world.Liquid) float64 { + if liquid.LiquidFalling() { + return 1 + } + return float64(liquid.LiquidDepth()+1) / 9 +} + +func (s *Simulator) containsAnyLiquid(box cube.BBox) bool { + min, max := box.Min(), box.Max() + minX, minY, minZ := int(math.Floor(min.X())), int(math.Floor(min.Y())), int(math.Floor(min.Z())) + maxX, maxY, maxZ := int(math.Ceil(max.X())), int(math.Ceil(max.Y())), int(math.Ceil(max.Z())) + for x := minX; x < maxX; x++ { + for z := minZ; z < maxZ; z++ { + for y := minY; y < maxY; y++ { + if _, ok := s.liquidAt(cube.Pos{x, y, z}); ok { + return true + } + } + } + } + return false +} + +func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, kind liquidKind) { + flow := mgl64.Vec3{} + for _, pos := range positions { + liquid, ok := s.liquidAt(pos) + if !ok || !kind.matches(liquid) { + continue + } + flow = flow.Add(s.liquidFlow(pos, liquid)) + } + if length := flow.Len(); length >= 1e-4 { + strength := 0.014 + if kind == liquidLava { + strength = 0.0035 + } + state.SetVel(state.Vel.Add(flow.Mul(strength / length))) + s.debugf("%s flow applied strength=%.6f flow=%v vel=%v", kind.typeName(), strength, flow, state.Vel) + } +} + +func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl64.Vec3 { + currentDecay := liquidDecay(liquid) + flow := mgl64.Vec3{} + for _, face := range liquidFaces { + neighbourPos := pos.Add(face.delta) + if neighbour, ok := s.liquidAt(neighbourPos); ok { + if neighbour.LiquidType() == liquid.LiquidType() { + if !s.liquidFlowSideClosed(pos, neighbourPos) && !s.liquidFlowSideClosed(neighbourPos, pos) { + flow = flow.Add(face.vec.Mul(float64(liquidDecay(neighbour) - currentDecay))) + } + continue + } + } + if len(s.blockCollisions(neighbourPos)) != 0 { + continue + } + below := neighbourPos.Side(cube.FaceDown) + if lower, ok := s.liquidAt(below); ok && lower.LiquidType() == liquid.LiquidType() { + flow = flow.Add(face.vec.Mul(float64(liquidDecay(lower) - currentDecay + 8))) + } + } + if liquid.LiquidFalling() { + for _, face := range liquidFaces { + neighbourPos := pos.Add(face.delta) + aboveNeighbour := neighbourPos.Side(cube.FaceUp) + if len(s.blockCollisions(neighbourPos)) != 0 || len(s.blockCollisions(aboveNeighbour)) != 0 { + if length := flow.Len(); length > 1e-4 { + flow = flow.Mul(1 / length) + } + flow[1] -= 6 + break + } + } + } + if length := flow.Len(); length > 1e-4 { + return flow.Mul(1 / length) + } + return mgl64.Vec3{} +} + +func (s *Simulator) liquidFlowSideClosed(pos, side cube.Pos) bool { + stairs, ok := s.blockAtPos(pos).(block.Stairs) + return ok && stairs.Model().FaceSolid(pos, pos.Face(side), s.World) +} + +func liquidDecay(liquid world.Liquid) int { + if liquid.LiquidFalling() { + return 0 + } + return 8 - liquid.LiquidDepth() +} diff --git a/liquid_hardening_test.go b/liquid_hardening_test.go new file mode 100644 index 0000000..008290c --- /dev/null +++ b/liquid_hardening_test.go @@ -0,0 +1,653 @@ +package bedsim + +import ( + "math" + "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/mgl64" +) + +func dryState() *MovementState { + state := submergedState() + state.Gravity = NormalGravity + return state +} + +// A client that latches the swimming flag while in open air must not keep water +// travel — and therefore zero gravity — indefinitely. +func TestSpoofedSwimmingInAirStopsHovering(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + + startY := state.Pos.Y() + for range 60 { + sim.Simulate(state, InputState{}) + } + + if state.Vel.Y() > -0.5 { + t.Fatalf("vertical velocity = %v, want a clear fall after the grace expires", state.Vel.Y()) + } + if state.Pos.Y() >= startY { + t.Fatalf("position Y = %v, want below the start %v", state.Pos.Y(), startY) + } +} + +// The grace window exists so a swimmer at the surface, whose hitbox briefly +// stops overlapping a water block, keeps water travel across the transition. +func TestSwimmingPreservesWaterTravelAcrossSurfaceTransition(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + // Simulate prior water contact by seeding the grace the same way a real + // contact tick would. + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + // Water travel while swimming applies no gravity at all. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("vertical velocity = %v, want water travel (0) inside the grace window", state.Vel.Y()) + } +} + +// Real water contact refills the grace to its full window every tick. +func TestSwimWaterGraceRefillsOnWaterContact(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = 1 + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != DefaultSwimWaterGraceTicks { + t.Fatalf("grace = %d, want refilled to %d", state.SwimWaterGraceTicks, DefaultSwimWaterGraceTicks) + } +} + +// The grace decays by exactly one tick per dry tick and expires on schedule. +func TestSwimWaterGraceExpiresDeterministically(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.SwimWaterGraceTicks = 3 + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = 3 + + for tick := 1; tick <= 3; tick++ { + sim.SimulateState(state) + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("tick %d: vertical velocity = %v, want water travel", tick, state.Vel.Y()) + } + if want := int64(3 - tick); state.SwimWaterGraceTicks != want { + t.Fatalf("tick %d: grace = %d, want %d", tick, state.SwimWaterGraceTicks, want) + } + } + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0) { + t.Fatal("water travel must stop once the grace is exhausted") + } +} + +// A caller can widen the grace window through options. +func TestSwimWaterGraceOptionOverride(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Options.SwimWaterGraceTicks = 25 + state := submergedState() + state.Swimming = true + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != 25 { + t.Fatalf("grace = %d, want 25", state.SwimWaterGraceTicks) + } +} + +// A negative option disables the grace entirely, requiring water contact on +// every tick for water travel. +func TestSwimWaterGraceCanBeDisabled(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Options.SwimWaterGraceTicks = -1 + state := submergedState() + state.Swimming = true + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want 0 when disabled", state.SwimWaterGraceTicks) + } + + dry := newLiquidSim(newLiquidWorld()) + dry.Options.SwimWaterGraceTicks = -1 + dryS := dryState() + dryS.Swimming = true + dryS.SwimWaterGraceTicks = 5 // stale value must not grant travel + + dry.SimulateState(dryS) + if approxEqual(dryS.Vel.Y(), 0) { + t.Fatal("disabled grace must not permit water travel out of water") + } +} + +// An unreliable frame resets the retained proof, so it cannot be carried across +// a correction. +func TestSwimWaterGraceResetOnUnreliableFrame(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Alive = false // forces the unreliable path + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnreliable { + t.Fatalf("outcome = %v, want unreliable", result.Outcome) + } + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0", state.SwimWaterGraceTicks) + } +} + +// An unloaded chunk cannot prove water contact, so the retained proof is +// cleared rather than carried across the gap. +func TestSwimWaterGraceResetOnUnloadedChunk(t *testing.T) { + w := newLiquidWorld() + w.chunkLoaded = false + sim := newLiquidSim(w) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0", state.SwimWaterGraceTicks) + } +} + +// The grace never exceeds its configured bound, however long the player swims. +func TestSwimWaterGraceIsBounded(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + + for range 100 { + sim.SimulateState(state) + if state.SwimWaterGraceTicks > DefaultSwimWaterGraceTicks { + t.Fatalf("grace = %d, want at most %d", state.SwimWaterGraceTicks, DefaultSwimWaterGraceTicks) + } + } +} + +// Real water contact still takes priority over the swimming flag: a player in +// water gets water travel whether or not the client claims to be swimming. +func TestRealWaterContactDoesNotNeedSwimmingFlag(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = false + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// The security window's default is pinned so a regression cannot silently +// widen it. Every other grace test compares against the symbol or overrides it +// through options, so without this the constant is unconstrained. +func TestDefaultSwimWaterGraceTicksValue(t *testing.T) { + if DefaultSwimWaterGraceTicks != 10 { + t.Fatalf("DefaultSwimWaterGraceTicks = %d, want 10; widening this enlarges "+ + "the window in which a client-controlled flag alone drives water travel", + DefaultSwimWaterGraceTicks) + } +} + +// A teleport relocates the player without observing the destination, so +// retained water contact from the origin must not survive it. +func TestSwimWaterGraceResetOnTeleport(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.TeleportPos = mgl64.Vec3{50, 50, 50} + state.TeleportCompletionTicks = 3 + state.TicksSinceTeleport = 0 + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport", result.Outcome) + } + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0 across a teleport", state.SwimWaterGraceTicks) + } +} + +// Frozen ticks observe nothing, so the budget must not pause and resume later. +func TestSwimWaterGraceResetWhenImmobile(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Immobile = true + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeImmobileOrNotReady { + t.Fatalf("outcome = %v, want immobile", result.Outcome) + } + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0 while immobile", state.SwimWaterGraceTicks) + } +} + +// Lava the player is demonstrably standing in must win over a stale water +// grace, so a swimming flag cannot cancel lava gravity. +func TestLavaWinsOverStaleWaterGrace(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + // Lava gravity, not water travel's zero gravity for a swimmer. + assertVec(t, state.Vel, mgl64.Vec3{0, -0.02, 0}) +} + +// The swim-speed multiplier branch scales acceleration by +// (0.7 + depthStriderFraction*0.3) * multiplier. Comparing full Depth Strider +// against none pins that expression, which the golden cannot reach because it +// runs with a multiplier of 1. +func TestSwimSpeedMultiplierDepthStriderScaling(t *testing.T) { + run := func(level int) float64 { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = depthStriderInventory{level: level} + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.SwimSpeedMultiplier = 2 + state.OnGround = true + state.Impulse = mgl64.Vec2{0, 0.98} + sim.SimulateState(state) + return state.Vel.Z() + } + + none, full := run(0), run(3) + if approxEqual(none, 0) { + t.Fatal("baseline velocity must be non-zero") + } + // fraction 0 -> 0.7; fraction 1 -> 1.0. Drag is 0.8 in both cases because + // the Depth Strider drag term is gated on multiplier <= 1. + if ratio := full / none; math.Abs(ratio-1/0.7) > 1e-9 { + t.Fatalf("full/none acceleration ratio = %.17g, want %.17g", ratio, 1/0.7) + } +} + +type explicitLiquids struct { + layer map[cube.Pos]world.Liquid +} + +func (p explicitLiquids) Liquid(pos cube.Pos) (world.Liquid, bool) { + liquid, ok := p.layer[pos] + return liquid, ok +} + +func TestHasLiquidLayerReportsExplicitProvider(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + if sim.HasLiquidLayer() { + t.Fatal("a plain world must not report liquid layer support") + } + + sim.Liquids = explicitLiquids{layer: map[cube.Pos]world.Liquid{}} + if !sim.HasLiquidLayer() { + t.Fatal("an explicit Liquids provider must report support") + } +} + +// A world that implements LiquidProvider still works, preserving v0.1.3-era +// integrations that relied on the type assertion. +func TestHasLiquidLayerAcceptsWorldProvider(t *testing.T) { + sim := newLiquidSim(newLayeredLiquidWorld()) + if !sim.HasLiquidLayer() { + t.Fatal("a world implementing LiquidProvider must report support") + } +} + +// The explicit field wins over the world assertion when both are present. +func TestExplicitLiquidsFieldTakesPrecedence(t *testing.T) { + w := newLayeredLiquidWorld() + w.waterlog(cube.Pos{0, 0, 0}, block.Air{}, waterSource) + + sim := newLiquidSim(w) + sim.Liquids = explicitLiquids{layer: map[cube.Pos]world.Liquid{}} + + state := submergedState() + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { + t.Fatalf("water blocks = %d, want 0 from the explicit empty provider", got) + } +} + +// Liquids supplied through the explicit field are detected normally. +func TestExplicitLiquidsProviderDetectsWaterlogged(t *testing.T) { + layer := map[cube.Pos]world.Liquid{} + for y := range 4 { + layer[cube.Pos{0, y, 0}] = waterSource + } + sim := newLiquidSim(newLiquidWorld()) + sim.Liquids = explicitLiquids{layer: layer} + + state := submergedState() + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got == 0 { + t.Fatal("expected waterlogged blocks from the explicit provider") + } + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// With RequireLiquidLayer set, a simulator that cannot see layer 1 refuses to +// simulate rather than silently mis-simulating waterlogged blocks. +func TestRequireLiquidLayerFailsClosed(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.RequireLiquidLayer = true + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0.5, 0.5} + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnreliable { + t.Fatalf("outcome = %v, want unreliable when the liquid layer is unavailable", result.Outcome) + } +} + +// The same simulator proceeds normally once layer-1 support is supplied. +func TestRequireLiquidLayerPassesWithProvider(t *testing.T) { + sim := newLiquidSim(newLayeredLiquidWorld()) + sim.Options.RequireLiquidLayer = true + state := submergedState() + + result := sim.SimulateState(state) + if result.Outcome == SimulationOutcomeUnreliable { + t.Fatal("a simulator with liquid layer support must not fail closed") + } +} + +// Without the option, the legacy fallback still simulates, preserving +// compatibility for callers that do not track layer 1. +func TestLiquidLayerFallbackStillSimulatesByDefault(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeNormal { + t.Fatalf("outcome = %v, want normal", result.Outcome) + } +} + +// By default bedsim keeps its sneak and consumable impulse clamps; the opt-in +// selects upstream's behavior, which applies neither. +func TestUpstreamImpulseClampingOptIn(t *testing.T) { + cases := []struct { + name string + upstream bool + input InputState + want float64 + }{ + {"sneak default", false, InputState{SneakDown: true, MoveVector: mgl64.Vec2{0, 1}}, MaxSneakImpulse * 0.98}, + {"sneak upstream", true, InputState{SneakDown: true, MoveVector: mgl64.Vec2{0, 1}}, 0.98}, + {"consumable default", false, InputState{UsingConsumable: true, MoveVector: mgl64.Vec2{0, 1}}, MaxConsumingImpulse * 0.98}, + {"consumable upstream", true, InputState{UsingConsumable: true, MoveVector: mgl64.Vec2{0, 1}}, 0.98}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.UpstreamImpulseClamping = tc.upstream + state := newBaseState() + + sim.applyInput(state, tc.input) + if !approxEqual(state.Impulse.Y(), tc.want) { + t.Fatalf("impulse Y = %v, want %v", state.Impulse.Y(), tc.want) + } + }) + } +} + +// Upstream still clamps the raw move vector to the unit range. +func TestUpstreamImpulseClampingStillBoundsMoveVector(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.UpstreamImpulseClamping = true + state := newBaseState() + + sim.applyInput(state, InputState{MoveVector: mgl64.Vec2{5, -5}}) + if !approxEqual(state.Impulse.X(), 0.98) || !approxEqual(state.Impulse.Y(), -0.98) { + t.Fatalf("impulse = %v, want the move vector clamped to [-1, 1] then scaled", state.Impulse) + } +} + +// A flying player is treated as an unsupported scenario before physics run at +// all, so no liquid physics are applied and the state is reset to the client. +func TestFlyingIsUnreliableBeforePhysics(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Flying = true + state.Vel = mgl64.Vec3{0.25, 0.25, 0.25} + state.Client.Vel = mgl64.Vec3{1, 2, 3} + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnreliable { + t.Fatalf("outcome = %v, want unreliable", result.Outcome) + } + assertVec(t, state.Vel, mgl64.Vec3{1, 2, 3}) +} + +// The liquid gate itself also excludes flying, independently of the reliability +// check above. Exercised directly because the public path cannot reach it. +func TestLiquidGateExcludesFlying(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Flying = true + state.Gravity = NormalGravity + + sim.simulateMovement(state) + if approxEqual(state.Vel.Y(), -0.005) { + t.Fatal("flying must not take the liquid path") + } + if !approxEqual(state.Vel.Y(), -NormalGravity*NormalGravityMultiplier) { + t.Fatalf("vertical velocity = %v, want normal gravity", state.Vel.Y()) + } +} + +// The drop contribution weights an open neighbour with liquid below by +// (decay(below) - decay(current) + 8). Competing horizontal terms make the +// weight observable through the normalized result. +func TestFlowDropWeightIsEight(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{-1, 0, 0}, block.Water{Depth: 7}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). + set(cube.Pos{0, 0, -1}, block.Water{Depth: 8}). + set(cube.Pos{1, -1, 0}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + // +X: open with liquid below -> (0 - 0 + 8) = +8 + // -X: same-type neighbour -> (1 - 0) = -1 + // +Z: same-type neighbour -> (4 - 0) = +4 + want := mgl64.Vec3{7, 0, 4}.Normalize() + assertVec(t, flow, want) +} + +// Falling liquid blocked by a solid neighbour adds a downward term of exactly +// 6 against the unit-normalized horizontal flow. +func TestFallingFlowDownwardWeightIsSix(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{-1, 0, 0}, block.Water{Depth: 4}). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}) + // Horizontal flow normalizes to (-1, 0, 0), then Y -= 6, then normalizes. + want := mgl64.Vec3{-1, -6, 0}.Normalize() + assertVec(t, flow, want) +} + +// A waterlogged stairs block whose solid face points at the neighbour blocks +// flow through that face. +func TestStairsSolidFaceBlocksFlow(t *testing.T) { + build := func(facing cube.Direction) mgl64.Vec3 { + w := newLayeredLiquidWorld() + w.waterlog(cube.Pos{0, 0, 0}, block.Stairs{Facing: facing}, block.Water{Depth: 8}) + w.set(cube.Pos{1, 0, 0}, block.Water{Depth: 4}) + sim := newLiquidSim(w) + return sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + } + + // Facing east: the stairs' full side faces the +X neighbour and closes it. + if flow := build(cube.East); !approxEqual(flow.X(), 0) { + t.Fatalf("east-facing stairs: flow X = %v, want 0", flow.X()) + } + // Facing west: the +X side is open, so flow proceeds toward the shallower + // neighbour. + if flow := build(cube.West); !(flow.X() > 0) { + t.Fatalf("west-facing stairs: flow X = %v, want positive", flow.X()) + } +} + +// A simulator with no world must not panic on any liquid path. +func TestNilWorldIsSafe(t *testing.T) { + sim := &Simulator{Options: SimulationOptions{PositionCorrectionThreshold: 0.3}} + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { + t.Fatalf("water blocks = %d, want 0 with no world", got) + } + if sim.containsAnyLiquid(state.BoundingBox(false)) { + t.Fatal("no world must contain no liquid") + } + if flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}); flow.Len() != 0 { + t.Fatalf("flow = %v, want zero with no world", flow) + } + if sim.HasLiquidLayer() { + t.Fatal("no world must not report liquid layer support") + } +} + +// The collapsed swim hitbox fits under a ceiling that a standing hitbox would +// hit, so the swim pose genuinely changes collision results. +func TestSwimHitboxChangesCeilingCollision(t *testing.T) { + newCeilingSim := func() (*Simulator, *MovementState) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{1, 1, 1}, waterSource). + set(cube.Pos{0, 2, 0}, block.Stone{}) + state := submergedState() + // Starts clear of the ceiling in both poses; only the standing hitbox + // reaches it after the upward move. + state.Pos = mgl64.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl64.Vec3{0, 0.5, 0} + return newLiquidSim(w), state + } + + sim, standing := newCeilingSim() + sim.SimulateState(standing) + + sim2, swimming := newCeilingSim() + swimming.Swimming = true + // The swim pose requires server-observed water contact, which this state + // has: the player is standing in the water column below. + swimming.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + sim2.SimulateState(swimming) + + if !standing.CollideY { + t.Fatal("a standing hitbox must hit the ceiling") + } + if swimming.CollideY { + t.Fatal("a swimming hitbox must fit under the ceiling") + } +} + +// Repeated identical runs must agree exactly. A single pinned golden detects +// map-iteration nondeterminism only probabilistically, so this repeats the same +// scenario and compares runs against each other. +func TestLiquidSimulationIsRepeatable(t *testing.T) { + run := func() (mgl64.Vec3, mgl64.Vec3) { + w := newLiquidWorld(). + fill(cube.Pos{-8, 0, -8}, cube.Pos{8, 8, 8}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 6}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). + set(cube.Pos{-1, 1, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{2, 0, 2}, block.Stone{}) + sim := newLiquidSim(w) + sim.Inventory = depthStriderInventory{level: 2} + state := submergedState() + state.Swimming = true + state.SwimAmount = 0.5 + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + input := InputState{ + Jumping: true, + MoveVector: mgl64.Vec2{0.5, 1}, + Pitch: 25, + Yaw: 40, + HeadYaw: 40, + } + for range 20 { + sim.Simulate(state, input) + } + return state.Pos, state.Vel + } + + wantPos, wantVel := run() + for i := range 5 { + gotPos, gotVel := run() + if gotPos != wantPos || gotVel != wantVel { + t.Fatalf("run %d diverged: pos %v vs %v, vel %v vs %v", + i, gotPos, wantPos, gotVel, wantVel) + } + } +} + +// Pinned change detector for the mixed-liquid path; update deliberately. +func TestLiquidGoldenScenario(t *testing.T) { + // Deep enough that the player stays submerged for the whole run, so the + // golden measures liquid physics rather than a surface transition. + w := newLiquidWorld(). + fill(cube.Pos{-8, 0, -8}, cube.Pos{8, 8, 8}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 6}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). + set(cube.Pos{-1, 1, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{2, 0, 2}, block.Stone{}) + sim := newLiquidSim(w) + sim.Inventory = depthStriderInventory{level: 2} + + state := submergedState() + state.Swimming = true + state.SwimAmount = 0.5 + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + // Rotation and impulse must arrive through InputState: Simulate overwrites + // both from the input every tick, so seeding them on the state would leave + // the pitch steering and moveRelative terms untested. The swim speed + // multiplier is left at its default so the Depth Strider drag term, which + // is gated on multiplier <= 1, is actually reached. + input := InputState{ + Jumping: true, + MoveVector: mgl64.Vec2{0.5, 1}, + Pitch: 25, + Yaw: 40, + HeadYaw: 40, + } + for range 20 { + sim.Simulate(state, input) + } + + wantPos := mgl64.Vec3{-0.012654883672021777, 2.7281474976710665, 3.5954677602500538} + wantVel := mgl64.Vec3{-0.02702143903177032, 0.15437050046578696, 0.1142856536788795} + + const tolerance = 1e-12 + for axis, name := range []string{"X", "Y", "Z"} { + if math.Abs(state.Pos[axis]-wantPos[axis]) > tolerance { + t.Errorf("Pos.%s = %.17g, want %.17g", name, state.Pos[axis], wantPos[axis]) + } + if math.Abs(state.Vel[axis]-wantVel[axis]) > tolerance { + t.Errorf("Vel.%s = %.17g, want %.17g", name, state.Vel[axis], wantVel[axis]) + } + } +} diff --git a/liquid_test.go b/liquid_test.go new file mode 100644 index 0000000..07cf173 --- /dev/null +++ b/liquid_test.go @@ -0,0 +1,1393 @@ +package bedsim + +import ( + "math" + "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/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// waterSource and lavaSource are full still source blocks, the most common +// liquid a player is submerged in. +var ( + waterSource = block.Water{Depth: 8, Still: true} + lavaSource = block.Lava{Depth: 8, Still: true} +) + +// liquidWorld is a WorldProvider backed by explicit block placements. It +// deliberately does not implement LiquidProvider, so simulations against it +// exercise the fallback path through WorldProvider.Block. +type liquidWorld struct { + blocks map[cube.Pos]world.Block + chunkLoaded bool +} + +func newLiquidWorld() *liquidWorld { + return &liquidWorld{blocks: map[cube.Pos]world.Block{}, chunkLoaded: true} +} + +func (w *liquidWorld) set(pos cube.Pos, b world.Block) *liquidWorld { + w.blocks[pos] = b + return w +} + +// fill places b in the inclusive cuboid between min and max. +func (w *liquidWorld) fill(min, max cube.Pos, b world.Block) *liquidWorld { + for x := min[0]; x <= max[0]; x++ { + for y := min[1]; y <= max[1]; y++ { + for z := min[2]; z <= max[2]; z++ { + w.blocks[cube.Pos{x, y, z}] = b + } + } + } + return w +} + +func (w *liquidWorld) Block(pos cube.Pos) world.Block { + if b, ok := w.blocks[pos]; ok { + return b + } + return block.Air{} +} + +func (w *liquidWorld) BlockCollisions(pos cube.Pos) []cube.BBox { + b := w.Block(pos) + if _, air := b.(block.Air); air { + return nil + } + if _, liquid := b.(world.Liquid); liquid { + return nil + } + return []cube.BBox{cube.Box(0, 0, 0, 1, 1, 1).Translate(pos.Vec3())} +} + +func (w *liquidWorld) GetNearbyBBoxes(aabb cube.BBox) []cube.BBox { + min, max := aabb.Min(), aabb.Max() + var out []cube.BBox + for x := int(math.Floor(min.X())); x <= int(math.Floor(max.X())); x++ { + for y := int(math.Floor(min.Y())); y <= int(math.Floor(max.Y())); y++ { + for z := int(math.Floor(min.Z())); z <= int(math.Floor(max.Z())); z++ { + for _, bb := range w.BlockCollisions(cube.Pos{x, y, z}) { + if bb.IntersectsWith(aabb) { + out = append(out, bb) + } + } + } + } + } + return out +} + +func (w *liquidWorld) IsChunkLoaded(chunkX, chunkZ int32) bool { + return w.chunkLoaded +} + +// layeredLiquidWorld implements LiquidProvider, exposing liquids stored in the +// second block layer (waterlogged blocks) as well as the main layer. +type layeredLiquidWorld struct { + *liquidWorld + layer map[cube.Pos]world.Liquid +} + +func newLayeredLiquidWorld() *layeredLiquidWorld { + return &layeredLiquidWorld{liquidWorld: newLiquidWorld(), layer: map[cube.Pos]world.Liquid{}} +} + +func (w *layeredLiquidWorld) waterlog(pos cube.Pos, b world.Block, liquid world.Liquid) *layeredLiquidWorld { + w.blocks[pos] = b + w.layer[pos] = liquid + return w +} + +func (w *layeredLiquidWorld) Liquid(pos cube.Pos) (world.Liquid, bool) { + if liquid, ok := w.layer[pos]; ok { + return liquid, true + } + liquid, ok := w.Block(pos).(world.Liquid) + return liquid, ok +} + +type levitationEffects struct { + amplifier int32 +} + +func (e levitationEffects) GetEffect(effectID int32) (int32, bool) { + if effectID == packet.EffectLevitation { + return e.amplifier, true + } + return 0, false +} + +type depthStriderInventory struct { + level int +} + +func (depthStriderInventory) HasElytra() bool { return false } + +func (i depthStriderInventory) DepthStriderLevel() int { return i.level } + +func newLiquidSim(w WorldProvider) *Simulator { + return &Simulator{ + World: w, + Effects: mockEffects{}, + Options: SimulationOptions{PositionCorrectionThreshold: 0.3}, + } +} + +// submergedState returns a state standing inside a liquid column at 0.5/0.5/0.5. +func submergedState() *MovementState { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 0.5, 0.5} + state.Client.Pos = state.Pos + return state +} + +// filledColumn returns a world where the 3x3 column around the origin is filled +// with the given liquid from y=0 to y=3, so the player is fully submerged and +// the liquid gradient is uniform (no flow). +func filledColumn(b world.Block) *liquidWorld { + return newLiquidWorld().fill(cube.Pos{-2, 0, -2}, cube.Pos{2, 3, 2}, b) +} + +func approxEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-9 +} + +func assertVec(t *testing.T, got, want mgl64.Vec3) { + t.Helper() + if !approxEqual(got.X(), want.X()) || !approxEqual(got.Y(), want.Y()) || !approxEqual(got.Z(), want.Z()) { + t.Fatalf("velocity = %v, want %v", got, want) + } +} + +// A swimming player's hitbox collapses to a width-sized cube, matching the +// client's swim pose. This drives collision, liquid detection and exit probing. +func TestSwimmingBoundingBoxUsesWidthAsHeight(t *testing.T) { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 10, 0.5} + + standing := state.BoundingBox(false) + if height := standing.Height(); !approxEqual(height, 1.8) { + t.Fatalf("standing height = %v, want 1.8", height) + } + + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + swimming := state.BoundingBox(false) + if height := swimming.Height(); !approxEqual(height, 0.6) { + t.Fatalf("swimming height = %v, want 0.6", height) + } + if width := swimming.Width(); !approxEqual(width, standing.Width()) { + t.Fatalf("swimming width = %v, want unchanged %v", width, standing.Width()) + } +} + +// The swimming flag alone must not shrink the server-side hitbox: without +// server-observed water contact a client could otherwise claim the swim pose in +// open air and fit through gaps a standing player cannot. +func TestSwimmingFlagAloneDoesNotShrinkHitbox(t *testing.T) { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 10, 0.5} + state.Swimming = true + state.SwimWaterGraceTicks = 0 + + if state.SwimPose() { + t.Fatal("swim pose must require water evidence") + } + if height := state.BoundingBox(false).Height(); !approxEqual(height, 1.8) { + t.Fatalf("height = %v, want the full standing hitbox", height) + } + if height := state.ClientBoundingBox(false).Height(); !approxEqual(height, 1.8) { + t.Fatalf("client height = %v, want the full standing hitbox", height) + } +} + +// A spoofed swimming flag with no water anywhere must not let the player pass +// through a gap that only the collapsed swim hitbox fits. +func TestSpoofedSwimmingCannotFitThroughCeilingGap(t *testing.T) { + sim := newLiquidSim(newLiquidWorld().set(cube.Pos{0, 2, 0}, block.Stone{})) + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Swimming = true + state.Vel = mgl64.Vec3{0, 0.5, 0} + + sim.SimulateState(state) + if !state.CollideY { + t.Fatal("a spoofed swim pose must still collide with the ceiling") + } +} + +func TestSwimmingClientBoundingBoxUsesWidthAsHeight(t *testing.T) { + state := newBaseState() + state.Client.Pos = mgl64.Vec3{0.5, 10, 0.5} + + if height := state.ClientBoundingBox(false).Height(); !approxEqual(height, 1.8) { + t.Fatalf("standing client height = %v, want 1.8", height) + } + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + if height := state.ClientBoundingBox(false).Height(); !approxEqual(height, 0.6) { + t.Fatalf("swimming client height = %v, want 0.6", height) + } +} + +// The swim hitbox must scale with the entity size, not use a hardcoded 0.6. +func TestSwimmingBoundingBoxRespectsScale(t *testing.T) { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 10, 0.5} + state.Size = mgl64.Vec3{0.6, 1.8, 2} + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + if height := state.BoundingBox(false).Height(); !approxEqual(height, 1.2) { + t.Fatalf("scaled swimming height = %v, want 1.2", height) + } +} + +// Jumping is edge-triggered by StartJumping alone. A held jump key sets +// PressingJump and EffectiveJumping, but must not re-arm the ground jump. +func TestJumpingIsEdgeTriggeredByStartJumping(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + + sim.applyInput(state, InputState{Jumping: true}) + if state.Jumping { + t.Fatal("held jump must not set Jumping without StartJumping") + } + if !state.PressingJump { + t.Fatal("held jump must set PressingJump") + } + if !state.EffectiveJumping { + t.Fatal("held jump must set EffectiveJumping") + } + + sim.applyInput(state, InputState{StartJumping: true}) + if !state.Jumping { + t.Fatal("StartJumping must set Jumping") + } +} + +func TestEffectiveJumpingSources(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + for name, input := range map[string]InputState{ + "jumping": {Jumping: true}, + "autoJumpingInWater": {AutoJumpingInWater: true}, + "ascendBlock": {AscendBlock: true}, + } { + state := newBaseState() + sim.applyInput(state, input) + if !state.EffectiveJumping { + t.Fatalf("%s must set EffectiveJumping", name) + } + } + + state := newBaseState() + sim.applyInput(state, InputState{StartJumping: true}) + if state.EffectiveJumping { + t.Fatal("StartJumping alone must not set EffectiveJumping") + } +} + +// SwimAmount interpolates by 0.1 per tick, using the swimming state as it was +// at the start of the tick. +func TestSwimAmountInterpolation(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + + // The first StartSwimming tick still decays, because wasSwimming is false. + sim.applyInput(state, InputState{StartSwimming: true}) + if !state.Swimming { + t.Fatal("StartSwimming must set Swimming") + } + if !approxEqual(state.SwimAmount, 0) { + t.Fatalf("SwimAmount = %v, want 0", state.SwimAmount) + } + + for i := 1; i <= 3; i++ { + sim.applyInput(state, InputState{}) + if want := float64(i) * 0.1; !approxEqual(state.SwimAmount, want) { + t.Fatalf("tick %d: SwimAmount = %v, want %v", i, state.SwimAmount, want) + } + } + + sim.applyInput(state, InputState{StopSwimming: true}) + if state.Swimming { + t.Fatal("StopSwimming must clear Swimming") + } + if !approxEqual(state.SwimAmount, 0.4) { + t.Fatalf("SwimAmount = %v, want 0.4 (still rising on the stop tick)", state.SwimAmount) + } + sim.applyInput(state, InputState{}) + if !approxEqual(state.SwimAmount, 0.3) { + t.Fatalf("SwimAmount = %v, want 0.3", state.SwimAmount) + } +} + +func TestSwimAmountClampedToUnitRange(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + state.Swimming = true + for range 30 { + sim.applyInput(state, InputState{}) + } + if !approxEqual(state.SwimAmount, 1) { + t.Fatalf("SwimAmount = %v, want clamped to 1", state.SwimAmount) + } + + state.Swimming = false + for range 30 { + sim.applyInput(state, InputState{}) + } + if !approxEqual(state.SwimAmount, 0) { + t.Fatalf("SwimAmount = %v, want clamped to 0", state.SwimAmount) + } +} + +// StartSwimming cancels sneaking, since the two poses are mutually exclusive. +func TestStartSwimmingClearsSneaking(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + sim.applyInput(state, InputState{SneakDown: true, StartSneaking: true, StartSwimming: true}) + if state.Sneaking { + t.Fatal("StartSwimming must clear Sneaking") + } +} + +// StopSwimming wins when both flags arrive in the same tick. +func TestStopSwimmingTakesPriority(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + state.Swimming = true + sim.applyInput(state, InputState{StartSwimming: true, StopSwimming: true}) + if state.Swimming { + t.Fatal("StopSwimming must take priority over StartSwimming") + } +} + +// A player resting in still water sinks at the water gravity rate, with water +// drag applied before gravity. +func TestWaterDragAndGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) + + // Second tick: previous velocity is dragged by 0.8, then gravity applies. + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005*0.8 - 0.005, 0}) +} + +// Sprinting in water raises horizontal drag from 0.8 to 0.9. +func TestWaterSprintDrag(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + + normal := submergedState() + normal.Vel = mgl64.Vec3{0.5, 0, 0} + sim.SimulateState(normal) + + sprinting := submergedState() + sprinting.Vel = mgl64.Vec3{0.5, 0, 0} + sprinting.Sprinting = true + sim.SimulateState(sprinting) + + if !approxEqual(normal.Vel.X(), 0.5*0.8) { + t.Fatalf("walking drag: X = %v, want %v", normal.Vel.X(), 0.5*0.8) + } + if !approxEqual(sprinting.Vel.X(), 0.5*0.9) { + t.Fatalf("sprinting drag: X = %v, want %v", sprinting.Vel.X(), 0.5*0.9) + } +} + +// Vertical drag in water is always 0.8, independent of sprinting. +func TestWaterVerticalDragIndependentOfSprint(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Vel = mgl64.Vec3{0, 0.5, 0} + state.Sprinting = true + sim.SimulateState(state) + + assertVec(t, state.Vel, mgl64.Vec3{0, 0.5*0.8 - 0.005, 0}) +} + +// Lava uses a flat 0.5 drag on every axis and a heavier 0.02 gravity. +func TestLavaDragAndGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.Vel = mgl64.Vec3{0.4, 0.4, 0.4} + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0.2, 0.4*0.5 - 0.02, 0.2}) +} + +// Swimming removes water gravity entirely. +func TestSwimmingCancelsWaterGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{0, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("swimming vertical velocity = %v, want 0", state.Vel.Y()) + } +} + +// Gravity is skipped entirely when the state has no gravity. +func TestNoGravityInLiquid(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.HasGravity = false + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{}) +} + +// Levitation replaces liquid gravity with a pull toward the levitation target. +func TestLevitationOverridesLiquidGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Effects = levitationEffects{amplifier: 0} + state := submergedState() + + sim.SimulateState(state) + // target = 0.05 * (0+1); vel += (target - vel) * 0.2 + assertVec(t, state.Vel, mgl64.Vec3{0, 0.05 * 0.2, 0}) +} + +func TestLevitationAmplifierScales(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Effects = levitationEffects{amplifier: 3} + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, (LevitationGravityMultiplier * 4) * 0.2, 0}) +} + +// A nil effects provider must not panic and must fall back to gravity. +func TestNilEffectsProviderFallsBackToGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Effects = nil + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// Falling into liquid clears accumulated fall distance. +func TestLiquidResetsFallDistance(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.FallDistance = 12 + + sim.SimulateState(state) + if state.FallDistance != 0 { + t.Fatalf("FallDistance = %v, want 0", state.FallDistance) + } +} + +// Holding jump underwater adds a fixed 0.04 ascent impulse. +func TestEffectiveJumpingAscendsInWater(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.EffectiveJumping = true + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, 0.04*0.8 - 0.005, 0}) +} + +// Mid-transition into the swim pose zeroes the ascent instead of applying it. +func TestSwimTransitionZeroesJumpAscent(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.EffectiveJumping = true + state.SwimAmount = 0.5 + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// A fully-transitioned swimmer still ascends normally. +func TestFullSwimAmountStillAscends(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.EffectiveJumping = true + state.SwimAmount = 1 + + sim.SimulateState(state) + if state.Vel.Y() <= 0 { + t.Fatalf("vertical velocity = %v, want positive ascent", state.Vel.Y()) + } +} + +// WantDown and WantDownSlow each sink the player by 0.04 before drag. +func TestWantDownSinksInWater(t *testing.T) { + for name, apply := range map[string]func(*MovementState){ + "wantDown": func(s *MovementState) { s.WantDown = true }, + "wantDownSlow": func(s *MovementState) { s.WantDownSlow = true }, + } { + t.Run(name, func(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + apply(state) + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.04*0.8 - 0.005, 0}) + }) + } +} + +// The descend inputs are water-only; lava ignores them. +func TestWantDownIgnoredInLava(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.WantDown = true + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.02, 0}) +} + +// The descend inputs must not alter the sneak impulse clamp. Upstream dropped +// the clamp entirely in this PR, but that is a breaking, non-liquid API change +// that bedsim deliberately does not follow, so behavior stays as in v0.1.3. +func TestDescendInputsDoNotChangeSneakImpulseClamp(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + + sneaking := newBaseState() + sim.applyInput(sneaking, InputState{SneakDown: true, MoveVector: mgl64.Vec2{0, 1}}) + + descending := newBaseState() + sim.applyInput(descending, InputState{SneakDown: true, WantDown: true, MoveVector: mgl64.Vec2{0, 1}}) + + if !approxEqual(descending.Impulse.Y(), sneaking.Impulse.Y()) { + t.Fatalf("descending impulse %v must match sneaking impulse %v", + descending.Impulse.Y(), sneaking.Impulse.Y()) + } + if !approxEqual(sneaking.Impulse.Y(), MaxSneakImpulse*0.98) { + t.Fatalf("sneaking impulse = %v, want %v", sneaking.Impulse.Y(), MaxSneakImpulse*0.98) + } +} + +// While swimming, pitch steers vertical velocity toward -sin(pitch). +func TestSwimTravelFollowsPitch(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} // looking straight up + + sim.SimulateState(state) + // targetY = -sin(-90deg) = 1; vel += (1 - 0) * 0.06, then drag 0.8. + assertVec(t, state.Vel, mgl64.Vec3{0, 0.06 * 0.8, 0}) +} + +// A steep downward pitch uses the faster 0.085 interpolation rate. +func TestSwimTravelUsesFasterRateWhenDivingSteeply(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{90, 0, 0} // looking straight down + + sim.SimulateState(state) + // targetY = -sin(90deg) = -1, below -0.2 so rate is 0.085. + assertVec(t, state.Vel, mgl64.Vec3{0, -0.085 * 0.8, 0}) +} + +// Swim travel is suppressed while jumping, letting the jump impulse win. +func TestSwimTravelSkippedWhileJumping(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.EffectiveJumping = true + state.SwimAmount = 1 + state.Rotation = mgl64.Vec3{90, 0, 0} + + sim.SimulateState(state) + // Pitch steering skipped, so only the 0.04 jump impulse applies. + assertVec(t, state.Vel, mgl64.Vec3{0, 0.04 * 0.8, 0}) +} + +// Swimming upward at the surface stops the climb once the head clears the +// liquid, preventing the player from swimming out into open air. +func TestSwimTravelStopsAtSurface(t *testing.T) { + // Liquid only below the player's head-check probes. + w := newLiquidWorld().fill(cube.Pos{-2, -4, -2}, cube.Pos{2, 0, 2}, waterSource) + sim := newLiquidSim(w) + state := submergedState() + // Both head probes (+0.52 and +0.42) clear the liquid surface at y=1. + state.Pos = mgl64.Vec3{0.5, 1.5, 0.5} + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} + state.Vel = mgl64.Vec3{0, 0.5, 0} + // The hitbox has just left the water, so water travel is still in its + // grace window. + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + // Vertical velocity is zeroed at the surface, and swimming has no gravity. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("surface vertical velocity = %v, want 0", state.Vel.Y()) + } +} + +// While the head is still submerged the climb continues normally. +func TestSwimTravelContinuesWhileHeadSubmerged(t *testing.T) { + w := newLiquidWorld().fill(cube.Pos{-2, -4, -2}, cube.Pos{2, 0, 2}, waterSource) + sim := newLiquidSim(w) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} + state.Vel = mgl64.Vec3{0, 0.5, 0} + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0) { + t.Fatal("a submerged head must not trigger the surface clamp") + } +} + +// WantDownSlow suppresses the surface clamp so the player can hover. +func TestSwimTravelSurfaceClampSkippedWhenWantDownSlow(t *testing.T) { + w := newLiquidWorld().fill(cube.Pos{-2, -4, -2}, cube.Pos{2, 0, 2}, waterSource) + sim := newLiquidSim(w) + state := submergedState() + state.Pos = mgl64.Vec3{0.5, 1.5, 0.5} + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} + state.WantDownSlow = true + state.Vel = mgl64.Vec3{0, 0.5, 0} + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0) { + t.Fatal("WantDownSlow 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) { + base := newLiquidSim(filledColumn(waterSource)) + baseState := submergedState() + baseState.Vel = mgl64.Vec3{0.5, 0, 0} + base.SimulateState(baseState) + + strider := newLiquidSim(filledColumn(waterSource)) + strider.Inventory = depthStriderInventory{level: 3} + striderState := submergedState() + striderState.Vel = mgl64.Vec3{0.5, 0, 0} + striderState.OnGround = true + strider.SimulateState(striderState) + + if !approxEqual(baseState.Vel.X(), 0.5*0.8) { + t.Fatalf("base drag X = %v, want %v", baseState.Vel.X(), 0.5*0.8) + } + // Level 3 on the ground: drag = 0.8 + (0.54600006 - 0.8) * 1. + if !approxEqual(striderState.Vel.X(), 0.5*0.54600006) { + t.Fatalf("depth strider drag X = %v, want %v", striderState.Vel.X(), 0.5*0.54600006) + } + if !(striderState.Vel.X() < baseState.Vel.X()) { + t.Fatalf("depth strider X = %v must decay faster than base X = %v", + striderState.Vel.X(), baseState.Vel.X()) + } +} + +// Depth Strider raises water acceleration from the underwater speed toward the +// player's full movement speed. +func TestDepthStriderIncreasesAcceleration(t *testing.T) { + base := newLiquidSim(filledColumn(waterSource)) + baseState := submergedState() + baseState.Impulse = mgl64.Vec2{0, 0.98} + base.SimulateState(baseState) + + strider := newLiquidSim(filledColumn(waterSource)) + strider.Inventory = depthStriderInventory{level: 3} + striderState := submergedState() + striderState.Impulse = mgl64.Vec2{0, 0.98} + striderState.OnGround = true + strider.SimulateState(striderState) + + if !(math.Abs(striderState.Vel.Z()) > math.Abs(baseState.Vel.Z())) { + t.Fatalf("depth strider Z = %v must exceed base Z = %v", + striderState.Vel.Z(), baseState.Vel.Z()) + } +} + +// Depth Strider is halved while airborne (not standing on the floor). +func TestDepthStriderHalvedWhenAirborne(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = depthStriderInventory{level: 3} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + state.OnGround = false + + sim.SimulateState(state) + // level 1.5 -> fraction 0.5 -> drag = 0.8 + (0.54600006 - 0.8) * 0.5. + want := 0.5 * (0.8 + (0.54600006-0.8)*0.5) + if !approxEqual(state.Vel.X(), want) { + t.Fatalf("airborne depth strider X = %v, want %v", state.Vel.X(), want) + } +} + +// Depth Strider levels above the enchantment maximum are clamped to 3. +func TestDepthStriderClampedToMaxLevel(t *testing.T) { + clamped := newLiquidSim(filledColumn(waterSource)) + clamped.Inventory = depthStriderInventory{level: 99} + clampedState := submergedState() + clampedState.Vel = mgl64.Vec3{0.5, 0, 0} + clampedState.OnGround = true + clamped.SimulateState(clampedState) + + if !approxEqual(clampedState.Vel.X(), 0.5*0.54600006) { + t.Fatalf("clamped depth strider X = %v, want level-3 behavior", clampedState.Vel.X()) + } +} + +// A negative level must not amplify movement. +func TestDepthStriderNegativeLevelIgnored(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = depthStriderInventory{level: -5} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.5*0.8) { + t.Fatalf("negative depth strider X = %v, want plain water drag", state.Vel.X()) + } +} + +// Depth Strider only applies in water, never in lava. +func TestDepthStriderIgnoredInLava(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + sim.Inventory = depthStriderInventory{level: 3} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.5*0.5) { + t.Fatalf("lava X = %v, want flat 0.5 drag", state.Vel.X()) + } +} + +// An inventory that does not implement DepthStriderProvider must be ignored. +func TestInventoryWithoutDepthStriderProvider(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = mockInventory{} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.5*0.8) { + t.Fatalf("X = %v, want plain water drag", state.Vel.X()) + } +} + +// A dolphin boost raises the swim speed multiplier, which only takes effect +// while actually swimming. +func TestSwimSpeedMultiplierRequiresSwimming(t *testing.T) { + boosted := newLiquidSim(filledColumn(waterSource)) + boostedState := submergedState() + boostedState.Swimming = true + boostedState.SwimSpeedMultiplier = 2 + boostedState.Impulse = mgl64.Vec2{0, 0.98} + boosted.SimulateState(boostedState) + + plain := newLiquidSim(filledColumn(waterSource)) + plainState := submergedState() + plainState.Swimming = true + plainState.SwimSpeedMultiplier = 1 + plainState.Impulse = mgl64.Vec2{0, 0.98} + plain.SimulateState(plainState) + + if !(math.Abs(boostedState.Vel.Z()) > math.Abs(plainState.Vel.Z())) { + t.Fatalf("boosted Z = %v must exceed plain Z = %v", boostedState.Vel.Z(), plainState.Vel.Z()) + } + + notSwimming := newLiquidSim(filledColumn(waterSource)) + notSwimmingState := submergedState() + notSwimmingState.SwimSpeedMultiplier = 2 + notSwimmingState.Impulse = mgl64.Vec2{0, 0.98} + notSwimming.SimulateState(notSwimmingState) + + if !approxEqual(notSwimmingState.Vel.Z(), plainState.Vel.Z()) { + t.Fatalf("non-swimming Z = %v, want unboosted %v", notSwimmingState.Vel.Z(), plainState.Vel.Z()) + } +} + +// The dolphin boost expires on its own tick counter and restores the default +// multiplier when it runs out. +func TestDolphinBoostTicksExpire(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.DolphinBoostTicks = 2 + state.SwimSpeedMultiplier = 2 + + sim.Simulate(state, InputState{}) + if state.DolphinBoostTicks != 1 { + t.Fatalf("DolphinBoostTicks = %d, want 1", state.DolphinBoostTicks) + } + if !approxEqual(state.SwimSpeedMultiplier, 2) { + t.Fatalf("SwimSpeedMultiplier = %v, want still boosted", state.SwimSpeedMultiplier) + } + + sim.Simulate(state, InputState{}) + if state.DolphinBoostTicks != 0 { + t.Fatalf("DolphinBoostTicks = %d, want 0", state.DolphinBoostTicks) + } + if !approxEqual(state.SwimSpeedMultiplier, DefaultSwimSpeedMultiplier) { + t.Fatalf("SwimSpeedMultiplier = %v, want reset to %v", state.SwimSpeedMultiplier, DefaultSwimSpeedMultiplier) + } +} + +// An unset multiplier must not zero out water acceleration. +func TestZeroSwimSpeedMultiplierTreatedAsDefault(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.SwimSpeedMultiplier = 0 + state.Impulse = mgl64.Vec2{0, 0.98} + sim.SimulateState(state) + + explicit := newLiquidSim(filledColumn(waterSource)) + explicitState := submergedState() + explicitState.Swimming = true + explicitState.SwimSpeedMultiplier = DefaultSwimSpeedMultiplier + explicitState.Impulse = mgl64.Vec2{0, 0.98} + explicit.SimulateState(explicitState) + + assertVec(t, state.Vel, explicitState.Vel) +} + +// Unset movement speeds fall back to the documented defaults. +func TestZeroMovementSpeedsUseDefaults(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.UnderwaterMovementSpeed = 0 + state.Impulse = mgl64.Vec2{0, 0.98} + sim.SimulateState(state) + + explicit := newLiquidSim(filledColumn(waterSource)) + explicitState := submergedState() + explicitState.UnderwaterMovementSpeed = DefaultUnderwaterMovementSpeed + explicitState.Impulse = mgl64.Vec2{0, 0.98} + explicit.SimulateState(explicitState) + + assertVec(t, state.Vel, explicitState.Vel) +} + +// Water is detected through a shallow vertical offset, so a player standing on +// top of a water block is still considered to be in water. +func TestWaterDetectedAtFeet(t *testing.T) { + sim := newLiquidSim(newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource)) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 1 { + t.Fatalf("water blocks = %d, want 1", got) + } +} + +// Lava uses a wider horizontal shrink than water, so a player at the very edge +// of a lava block touches water but not lava. +func TestLavaUsesWiderHorizontalMargin(t *testing.T) { + w := newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource).set(cube.Pos{1, 0, 0}, lavaSource) + sim := newLiquidSim(w) + state := submergedState() + // Position the player so the box only just reaches into x=1. + state.Pos = mgl64.Vec3{0.75, 0.5, 0.5} + + water := sim.touchingLiquidBlocks(state, liquidWater) + lava := sim.touchingLiquidBlocks(state, liquidLava) + if len(water) == 0 { + t.Fatal("expected water contact") + } + if len(lava) != 0 { + t.Fatalf("lava blocks = %d, want 0 due to the wider lava margin", len(lava)) + } +} + +// Liquid type filtering must not mix water and lava. +func TestLiquidTypeFiltering(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { + t.Fatalf("water blocks = %d, want 0 in a lava column", got) + } + if got := len(sim.touchingLiquidBlocks(state, liquidLava)); got == 0 { + t.Fatal("expected lava contact") + } +} + +// Water travel takes priority when a player touches both liquids. +func TestWaterTakesPriorityOverLava(t *testing.T) { + w := newLiquidWorld(). + fill(cube.Pos{-2, 0, -2}, cube.Pos{2, 3, 2}, waterSource). + set(cube.Pos{0, 0, 0}, lavaSource) + sim := newLiquidSim(w) + state := submergedState() + + sim.SimulateState(state) + // Water gravity (0.005), not lava gravity (0.02). + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// Without a LiquidProvider, liquids are read from WorldProvider.Block. +func TestLiquidFallsBackToBlockProvider(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// A LiquidProvider exposes waterlogged blocks whose main layer is a solid. +func TestLiquidProviderDetectsWaterloggedBlocks(t *testing.T) { + w := newLayeredLiquidWorld() + for y := range 4 { + w.waterlog(cube.Pos{0, y, 0}, block.Air{}, waterSource) + } + sim := newLiquidSim(w) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got == 0 { + t.Fatal("expected waterlogged blocks to register as water") + } + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// A world with no liquids at all must run normal (non-liquid) physics. +func TestNoLiquidRunsNormalPhysics(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := submergedState() + // Gravity is normally seeded by applyInput; SimulateState uses state as-is. + state.Gravity = NormalGravity + + sim.SimulateState(state) + // Normal air gravity subtracts first and then applies the 0.98 multiplier, + // unlike the liquid path which applies drag before gravity. + if approxEqual(state.Vel.Y(), -0.005) { + t.Fatal("dry world must not use liquid gravity") + } + if !approxEqual(state.Vel.Y(), -NormalGravity*NormalGravityMultiplier) { + t.Fatalf("vertical velocity = %v, want normal gravity", state.Vel.Y()) + } +} + +// Unloaded chunks must fail safe and cancel movement rather than simulating. +func TestUnloadedChunkCancelsLiquidSimulation(t *testing.T) { + w := filledColumn(waterSource) + w.chunkLoaded = false + sim := newLiquidSim(w) + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0.5, 0.5} + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk", result.Outcome) + } + assertVec(t, state.Vel, mgl64.Vec3{}) +} + +// Being inside a liquid is a reliable scenario; v0.1.3 bailed out here. +func TestLiquidIsReliableScenario(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + result := sim.SimulateState(state) + if result.Outcome == SimulationOutcomeUnreliable { + t.Fatal("liquid contact must not mark the simulation unreliable") + } +} + +// Flying players ignore liquid physics entirely. +// Flying is covered by TestFlyingIsUnreliableBeforePhysics and +// TestLiquidGateExcludesFlying in liquid_hardening_test.go, which separate the +// reliability bail-out from the liquid gate itself. + +// Entering water cancels an active glide and its boost. +func TestWaterCancelsGliding(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Gliding = true + state.GlideBoostTicks = 15 + + sim.SimulateState(state) + if state.Gliding { + t.Fatal("water must cancel gliding") + } + if state.GlideBoostTicks != 0 { + t.Fatalf("GlideBoostTicks = %d, want 0", state.GlideBoostTicks) + } +} + +// A glide boost held by a player who is not gliding survives water contact. +func TestWaterPreservesGlideBoostWhenNotGliding(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Gliding = false + state.GlideBoostTicks = 15 + + sim.SimulateState(state) + if state.GlideBoostTicks != 15 { + t.Fatalf("GlideBoostTicks = %d, want 15 preserved when not gliding", state.GlideBoostTicks) + } +} + +// Lava does not cancel gliding; only water travel does. +func TestLavaDoesNotCancelGliding(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.Gliding = true + state.GlideBoostTicks = 15 + + sim.SimulateState(state) + if !state.Gliding { + t.Fatal("lava must not cancel gliding") + } + if state.GlideBoostTicks != 15 { + t.Fatalf("GlideBoostTicks = %d, want 15", state.GlideBoostTicks) + } +} + +// A swimming player keeps water physics even after leaving the water blocks, +// until the client reports that swimming stopped. +func TestSwimmingPreservesWaterTravelOutsideWater(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{0, 0, 0} + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + // Seeded so that falling back to normal physics would be visible as a + // gravity pull rather than an indistinguishable zero. + state.Gravity = NormalGravity + + sim.SimulateState(state) + // Water travel with swimming applies no gravity at all. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("vertical velocity = %v, want water travel (0)", state.Vel.Y()) + } +} + +// Jumping while swimming outside the water is suppressed, so the player cannot +// gain height in open air. +func TestSwimmingOutsideWaterSuppressesJump(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := submergedState() + state.Swimming = true + state.SwimAmount = 1 + state.EffectiveJumping = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Gravity = NormalGravity + + sim.SimulateState(state) + // Exactly zero: the jump impulse is suppressed and swimming cancels + // gravity, which distinguishes this from both an ascent and a normal fall. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("vertical velocity = %v, want exactly 0 outside water", state.Vel.Y()) + } + + // The same state actually in water does ascend, confirming the assertion + // above is not vacuous. + wet := newLiquidSim(filledColumn(waterSource)) + wetState := submergedState() + wetState.Swimming = true + wetState.SwimAmount = 1 + wetState.EffectiveJumping = true + wet.SimulateState(wetState) + if wetState.Vel.Y() <= 0 { + t.Fatalf("in-water vertical velocity = %v, want an ascent", wetState.Vel.Y()) + } +} + +// Flowing water pushes the player toward the lower-depth neighbour. +func TestLiquidFlowPushesTowardLowerDepth(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 7}). + set(cube.Pos{-1, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 8}). + set(cube.Pos{0, 0, -1}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + state := submergedState() + + sim.SimulateState(state) + if !(state.Vel.X() > 0) { + t.Fatalf("X velocity = %v, want a positive push toward the shallower neighbour", state.Vel.X()) + } +} + +// Water flow strength is 0.014 per tick along the normalized flow vector. +func TestWaterFlowStrength(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 7}) + sim := newLiquidSim(w) + state := submergedState() + + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, liquidWater), liquidWater) + if !approxEqual(state.Vel.X(), 0.014) { + t.Fatalf("flow X = %v, want 0.014", state.Vel.X()) + } +} + +// Lava flow is much weaker than water flow. +func TestLavaFlowStrength(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Lava{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Lava{Depth: 7}) + sim := newLiquidSim(w) + state := submergedState() + + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, liquidLava), liquidLava) + if !approxEqual(state.Vel.X(), 0.0035) { + t.Fatalf("flow X = %v, want 0.0035", state.Vel.X()) + } +} + +// A uniform liquid body produces no flow at all. +func TestUniformLiquidHasNoFlow(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, liquidWater), liquidWater) + assertVec(t, state.Vel, mgl64.Vec3{}) +} + +// Falling liquid against a solid neighbour gains a strong downward component. +func TestFallingLiquidFlowsDownwardAlongSolids(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}) + if !(flow.Y() < 0) { + t.Fatalf("falling liquid flow Y = %v, want negative", flow.Y()) + } +} + +// Non-falling liquid never gains the downward push. +func TestNonFallingLiquidHasNoDownwardFlow(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + if flow.Y() < 0 { + t.Fatalf("non-falling liquid flow Y = %v, want no downward push", flow.Y()) + } +} + +// A solid neighbour blocks flow in that direction rather than contributing. +func TestSolidNeighbourBlocksFlow(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Stone{}). + set(cube.Pos{1, -1, 0}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + if !approxEqual(flow.X(), 0) { + t.Fatalf("flow X = %v, want 0 through a solid neighbour", flow.X()) + } +} + +// An open neighbour with liquid below pulls the flow into the drop. +func TestFlowFallsIntoOpenDrop(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, -1, 0}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + if !(flow.X() > 0) { + t.Fatalf("flow X = %v, want a positive pull into the drop", flow.X()) + } +} + +// Flow below the epsilon threshold is discarded rather than applied. +func TestNegligibleFlowIgnored(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + before := state.Vel + + sim.applyLiquidFlow(state, nil, liquidWater) + assertVec(t, state.Vel, before) +} + +// Falling liquid is treated as full-height with zero decay. +func TestFallingLiquidDecayAndHeight(t *testing.T) { + falling := block.Water{Depth: 3, Falling: true} + if got := liquidDecay(falling); got != 0 { + t.Fatalf("falling decay = %d, want 0", got) + } + if got := liquidHeight(falling); !approxEqual(got, 1) { + t.Fatalf("falling height = %v, want 1", got) + } + + still := block.Water{Depth: 8} + if got := liquidDecay(still); got != 0 { + t.Fatalf("source decay = %d, want 0", got) + } + if got := liquidHeight(still); !approxEqual(got, 1) { + t.Fatalf("source height = %v, want 1", got) + } + + shallow := block.Water{Depth: 1} + if got := liquidDecay(shallow); got != 7 { + t.Fatalf("shallow decay = %d, want 7", got) + } + if got := liquidHeight(shallow); !approxEqual(got, 2.0/9.0) { + t.Fatalf("shallow height = %v, want 2/9", got) + } +} + +// Colliding horizontally with a ledge that has clear air above lets the player +// hop out of the liquid. +func TestLiquidExitProbeBoostsOverLedge(t *testing.T) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 0, 1}, waterSource). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + state.Impulse = mgl64.Vec2{0, 0.98} + + sim.SimulateState(state) + if !state.CollideX { + t.Fatal("expected a horizontal collision against the ledge") + } + if !approxEqual(state.Vel.Y(), 0.3) { + t.Fatalf("exit boost Y = %v, want 0.3", state.Vel.Y()) + } +} + +// A wall that continues above the player blocks the exit boost. +// Isolates the collision term of the exit probe. The two cases differ only by +// the block overhanging the probe box; in both, the probe box is verified to +// contain no liquid, so the liquid term cannot be what changes the result. +// The swim hitbox is used so the player fits below the overhang without +// colliding with it directly. +func TestLiquidExitProbeBlockedByCollisionAlone(t *testing.T) { + build := func(overhang bool) (*Simulator, *MovementState) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 0, 1}, waterSource). + set(cube.Pos{1, 0, 0}, block.Stone{}) + if overhang { + w.set(cube.Pos{0, 1, 0}, block.Stone{}) + } + state := submergedState() + state.Pos = mgl64.Vec3{0.5, 0.4, 0.5} + state.Client.Pos = state.Pos + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Vel = mgl64.Vec3{0.5, 0, 0} + return newLiquidSim(w), state + } + + for _, overhang := range []bool{false, true} { + sim, state := build(overhang) + raised := state.BoundingBox(false).Translate(mgl64.Vec3{0, 0.6, 0}) + if sim.containsAnyLiquid(raised) { + t.Fatalf("overhang=%t: probe box must contain no liquid to isolate the collision term", overhang) + } + + sim.SimulateState(state) + if !state.CollideX { + t.Fatalf("overhang=%t: expected a horizontal collision against the ledge", overhang) + } + if state.CollideY { + t.Fatalf("overhang=%t: the hitbox itself must not touch the overhang", overhang) + } + + boosted := approxEqual(state.Vel.Y(), 0.3) + if overhang && boosted { + t.Fatal("a block above the probe must deny the exit boost") + } + if !overhang && !boosted { + t.Fatalf("clear probe must grant the exit boost, got %v", state.Vel.Y()) + } + } +} + +// Liquid above the probe also blocks the exit boost, since the player is still +// submerged rather than at the surface. +func TestLiquidExitProbeBlockedByLiquidAbove(t *testing.T) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 4, 1}, waterSource). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0.3) { + t.Fatal("liquid above the probe must block the exit boost") + } +} + +// Without a horizontal collision there is no exit probe at all. +func TestNoExitProbeWithoutHorizontalCollision(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0.3) { + t.Fatal("exit boost must require a horizontal collision") + } +} + +// Climbing is driven by EffectiveJumping, so the automatic ascent inputs climb +// a ladder just like a held jump key does. +func TestClimbUsesEffectiveJumping(t *testing.T) { + for name, apply := range map[string]func(*MovementState){ + "pressingJump": func(s *MovementState) { s.PressingJump = true; s.EffectiveJumping = true }, + "autoJumpingInWater": func(s *MovementState) { s.EffectiveJumping = true }, + "none": func(s *MovementState) {}, + } { + t.Run(name, func(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.BlockSemantics = overrideBlockSemantics{ + name: "minecraft:ladder", + friction: DefaultBlockFriction, + climbable: true, + } + state := submergedState() + apply(state) + + sim.SimulateState(state) + // Climb speed is set during the tick, then the end-of-tick gravity + // multiplier applies. + climbing := approxEqual(state.Vel.Y(), ClimbSpeed*NormalGravityMultiplier) + if name == "none" && climbing { + t.Fatal("no ascent input must not climb") + } + if name != "none" && !climbing { + t.Fatalf("vertical velocity = %v, want ClimbSpeed", state.Vel.Y()) + } + }) + } +} + +// Determinism is covered by TestLiquidGoldenScenario in +// liquid_hardening_test.go, which pins the same mixed-liquid scenario to exact +// expected values rather than comparing the implementation against itself. + +// Shrinking a box past its own size collapses it to its midpoint instead of +// inverting it. +func TestShrinkLiquidBoxCollapsesToMidpoint(t *testing.T) { + box := cube.Box(0, 0, 0, 1, 0.2, 1) + shrunk := shrinkLiquidBox(box, mgl64.Vec3{0.001, 0.401, 0.001}) + + if !approxEqual(shrunk.Min().Y(), 0.1) || !approxEqual(shrunk.Max().Y(), 0.1) { + t.Fatalf("collapsed Y = [%v %v], want [0.1 0.1]", shrunk.Min().Y(), shrunk.Max().Y()) + } + if !approxEqual(shrunk.Min().X(), 0.001) || !approxEqual(shrunk.Max().X(), 0.999) { + t.Fatalf("X = [%v %v], want [0.001 0.999]", shrunk.Min().X(), shrunk.Max().X()) + } +} diff --git a/movement.go b/movement.go index e8fa77f..0f48fe8 100644 --- a/movement.go +++ b/movement.go @@ -36,10 +36,16 @@ type MovementState struct { JumpHeight float64 FallDistance float64 - MovementSpeed float64 - DefaultMovementSpeed float64 - AirSpeed float64 - ServerUpdatedSpeed bool + MovementSpeed float64 + DefaultMovementSpeed float64 + AirSpeed float64 + UnderwaterMovementSpeed float64 + LavaMovementSpeed float64 + // SwimSpeedMultiplier scales swimming acceleration; zero means the default. + SwimSpeedMultiplier float64 + // DolphinBoostTicks is the remaining dolphin-boost duration. + DolphinBoostTicks int64 + ServerUpdatedSpeed bool Knockback mgl64.Vec3 TicksSinceKnockback uint64 @@ -58,8 +64,16 @@ type MovementState struct { Sneaking, PressingSneak bool Jumping, PressingJump bool + EffectiveJumping bool JumpDelay uint64 + Swimming bool + SwimAmount float64 + // SwimWaterGraceTicks retains recent server-observed water contact. + SwimWaterGraceTicks int64 + AutoJumpingInWater bool + WantDown, WantDownSlow bool + CollideX, CollideY, CollideZ bool OnGround bool diff --git a/simulation.go b/simulation.go index a5b2241..ad1712e 100644 --- a/simulation.go +++ b/simulation.go @@ -51,6 +51,9 @@ func (s *Simulator) debugfIf(cond bool, format string, args ...any) { func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { 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 return SimulationOutcomeTeleport } @@ -59,12 +62,21 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { s.resetToClient(state) return SimulationOutcomeUnreliable } + if s.Options.RequireLiquidLayer && !s.HasLiquidLayer() { + s.debugf("no liquid layer available and RequireLiquidLayer is set") + s.resetToClient(state) + return SimulationOutcomeUnreliable + } if s.World != nil && !s.World.IsChunkLoaded(int32(math.Floor(state.Pos.X()))>>4, int32(math.Floor(state.Pos.Z()))>>4) { state.SetVel(mgl64.Vec3{}) + state.SwimWaterGraceTicks = 0 return SimulationOutcomeUnloadedChunk } if state.Immobile || !state.Ready { state.SetVel(mgl64.Vec3{}) + // Frozen ticks observe nothing, so the budget must not simply pause + // and resume later. + state.SwimWaterGraceTicks = 0 return SimulationOutcomeImmobileOrNotReady } @@ -173,21 +185,41 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.Sneaking = input.SneakDown } - maxImpulse := 1.0 - if input.UsingConsumable { - maxImpulse *= MaxConsumingImpulse + wasSwimming := state.Swimming + if input.StopSwimming { + state.Swimming = false + } else if input.StartSwimming { + state.Swimming = true + state.Sneaking = false } - if state.Sneaking { - maxImpulse *= MaxSneakImpulse + if wasSwimming { + state.SwimAmount = ClampFloat(state.SwimAmount+0.1, 0, 1) + } else { + state.SwimAmount = ClampFloat(state.SwimAmount-0.1, 0, 1) } + state.AutoJumpingInWater = input.AutoJumpingInWater + state.WantDown = input.WantDown + state.WantDownSlow = input.WantDownSlow + // Preserve bedsim's public impulse clamps unless upstream behavior is opted in. + maxImpulse := 1.0 + if !s.Options.UpstreamImpulseClamping { + if input.UsingConsumable { + maxImpulse *= MaxConsumingImpulse + } + if state.Sneaking { + maxImpulse *= MaxSneakImpulse + } + } moveVector := mgl64.Vec2{ ClampFloat(input.MoveVector[0], -maxImpulse, maxImpulse), ClampFloat(input.MoveVector[1], -maxImpulse, maxImpulse), } + // Ground jumps are edge-triggered; liquid and ladder ascent may be held. state.Jumping = input.StartJumping state.PressingJump = input.Jumping + state.EffectiveJumping = input.Jumping || input.AutoJumpingInWater || input.AscendBlock state.JumpHeight = DefaultJumpHeight if s.Effects != nil { if amp, ok := s.Effects.GetEffect(packet.EffectJumpBoost); ok { @@ -240,6 +272,13 @@ func (s *Simulator) tickState(state *MovementState) { if state.GlideBoostTicks > 0 { state.GlideBoostTicks-- } + if state.DolphinBoostTicks > 0 { + state.DolphinBoostTicks-- + if state.DolphinBoostTicks <= 0 { + state.DolphinBoostTicks = 0 + state.SwimSpeedMultiplier = DefaultSwimSpeedMultiplier + } + } state.TicksSinceKnockback++ if state.TicksSinceTeleport < math.MaxUint64 { state.TicksSinceTeleport++ @@ -255,6 +294,44 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SetVel(mgl64.Vec3{}) } + // Bound retained water evidence before collision and travel inspect it. + grace := s.swimWaterGraceTicks() + if state.SwimWaterGraceTicks > grace { + state.SwimWaterGraceTicks = grace + } + + waterBlocks := s.touchingLiquidBlocks(state, liquidWater) + lavaBlocks := s.touchingLiquidBlocks(state, liquidLava) + + inWater := len(waterBlocks) != 0 + defer func() { + if inWater { + state.SwimWaterGraceTicks = grace + } else if state.SwimWaterGraceTicks > 0 { + state.SwimWaterGraceTicks-- + } + }() + + // Observed lava takes precedence over retained water evidence. + waterTravel := inWater || + (state.Swimming && state.SwimWaterGraceTicks > 0 && len(lavaBlocks) == 0) + + if !state.Flying && (waterTravel || len(lavaBlocks) != 0) { + s.debugfIf(attemptKnockback(state), "knockback applied in liquid: %v", state.Vel) + if waterTravel { + if state.Gliding { + state.Gliding = false + state.GlideBoostTicks = 0 + } + s.applyLiquidFlow(state, waterBlocks, liquidWater) + s.simulateLiquidTravel(state, liquidWater, inWater) + } else { + s.applyLiquidFlow(state, lavaBlocks, liquidLava) + s.simulateLiquidTravel(state, liquidLava, true) + } + return + } + blockUnder := s.blockAtPos(cube.PosFromVec3(state.Pos.Sub(mgl64.Vec3{0, 0.5}))) blockFriction := DefaultAirFriction moveRelativeSpeed := state.AirSpeed @@ -299,14 +376,14 @@ func (s *Simulator) simulateMovement(state *MovementState) { if newVel[1] < negClimbSpeed { newVel[1] = negClimbSpeed } - if state.PressingJump || state.CollideX || state.CollideZ { + if state.EffectiveJumping || state.CollideX || state.CollideZ { newVel[1] = ClimbSpeed } if state.Sneaking && newVel[1] < 0 { newVel[1] = 0 } state.SetVel(newVel) - s.debugf("added climb velocity: %v (collided=%v pressingJump=%v)", newVel, state.CollideX || state.CollideZ, state.PressingJump) + s.debugf("added climb velocity: %v (collided=%v effectiveJumping=%v)", newVel, state.CollideX || state.CollideZ, state.EffectiveJumping) } inCobweb := s.isInsideCobweb(state) @@ -379,17 +456,10 @@ func (s *Simulator) simulationIsReliable(state *MovementState) bool { stateBB := state.BoundingBox(s.Options.UseSlideOffset) isReliable := true - for pos, b := range nearbyBlocks(stateBB.Grow(1), s.World) { + for _, b := range nearbyBlocks(stateBB.Grow(1), s.World) { if _, isAir := b.(block.Air); isAir { continue } - if _, isLiquid := b.(world.Liquid); isLiquid { - blockBB := cube.Box(0, 0, 0, 1, 1, 1).Translate(pos.Vec3()) - if stateBB.IntersectsWith(blockBB) { - isReliable = false - break - } - } if s.blockName(b) == "minecraft:bamboo" { isReliable = false break @@ -409,6 +479,9 @@ func (s *Simulator) simulationIsReliable(state *MovementState) bool { } 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.LastPos = state.Client.LastPos state.Pos = state.Client.Pos state.LastVel = state.Client.LastVel diff --git a/simulator.go b/simulator.go index bbaed58..f633d90 100644 --- a/simulator.go +++ b/simulator.go @@ -45,6 +45,16 @@ type SimulationOptions struct { // rejected due to the client position matching the pre-step position. IgnoreClientStepTiebreaker bool + // RequireLiquidLayer refuses simulation without second-layer liquid data. + RequireLiquidLayer bool + + // SwimWaterGraceTicks bounds retained water contact. Zero uses the default; + // a negative value disables retention. + SwimWaterGraceTicks int64 + + // UpstreamImpulseClamping opts into oomph PR #145's unclamped impulses. + UpstreamImpulseClamping bool + // Debugf receives internal simulation trace logs for callers that need deep diagnostics. Debugf func(format string, args ...any) } @@ -55,9 +65,12 @@ type Simulator struct { // BlockSemantics optionally resolves movement-specific block behavior from // the same world snapshot as World. Nil uses DefaultBlockSemantics. BlockSemantics BlockSemanticsProvider - Effects EffectsProvider - Inventory InventoryProvider - Options SimulationOptions + // Liquids exposes second-layer liquids. World is used when it implements + // LiquidProvider; otherwise waterlogged blocks are invisible. + Liquids LiquidProvider + Effects EffectsProvider + Inventory InventoryProvider + Options SimulationOptions } func (DefaultBlockSemantics) BlockName(b world.Block) string { @@ -72,6 +85,19 @@ func (DefaultBlockSemantics) BlockClimbable(b world.Block) bool { return BlockClimbable(b) } +// swimWaterGraceTicks resolves the configured grace window: zero means the +// default, and any negative value disables the grace entirely. +func (s *Simulator) swimWaterGraceTicks() int64 { + switch { + case s.Options.SwimWaterGraceTicks < 0: + return 0 + case s.Options.SwimWaterGraceTicks == 0: + return DefaultSwimWaterGraceTicks + default: + return s.Options.SwimWaterGraceTicks + } +} + func (s *Simulator) blockName(b world.Block) string { if s.BlockSemantics != nil { return s.BlockSemantics.BlockName(b)