using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; public class PhysicsEngineTests { private static float[] LinearHeightTable() { var table = new float[256]; for (int i = 0; i < 256; i++) table[i] = i * 1.0f; return table; } private static byte[] FlatHeightmap(byte value = 50) { var heights = new byte[81]; Array.Fill(heights, value); return heights; } private PhysicsEngine MakeFlatEngine(float terrainZ = 50f) { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap((byte)terrainZ), LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return engine; } [Fact] public void Resolve_FlatTerrain_ZMatchesTerrain() { var engine = MakeFlatEngine(terrainZ: 50f); var result = engine.Resolve( new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(1f, 0f, 0f), stepUpHeight: 2f); Assert.Equal(50f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } [Fact] public void Resolve_WalkUpSmallSlope_Accepted() { // Heights slope from 50 to 52 across X — small enough for step height. var heights = new byte[81]; for (int x = 0; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = (byte)(50 + x / 4); // gentle slope var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); var result = engine.Resolve( new Vector3(48f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f), stepUpHeight: 5f); Assert.True(result.IsOnGround); Assert.True(result.Position.Z >= 50f); // moved uphill } [Fact] public void Resolve_StepUpExceedsHeight_MovementBlocked() { // Heights jump sharply: left half = 50, right half = 100. var heights = new byte[81]; for (int x = 0; x < 9; x++) for (int y = 0; y < 9; y++) heights[x * 9 + y] = (byte)(x < 5 ? 50 : 100); var engine = new PhysicsEngine(); var terrain = new TerrainSurface(heights, LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Try to walk from the low side to the high side. var result = engine.Resolve( new Vector3(96f, 96f, 50f), cellId: 0x0001, delta: new Vector3(48f, 0f, 0f), stepUpHeight: 2f); // Movement should be blocked — Z delta (50→100) exceeds step height (2). Assert.Equal(96f, result.Position.X, precision: 1); // didn't move Assert.True(result.IsOnGround); } [Fact] public void Resolve_OutdoorThroughPortal_TransitionsToIndoor() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); // A CellSurface for the indoor cell with floor at Z=50. var cellVerts = new Dictionary { [0] = new(40f, 40f, 50f), [1] = new(60f, 40f, 50f), [2] = new(60f, 60f, 50f), [3] = new(40f, 60f, 50f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); // A portal plane at X=45 (vertical plane facing +X). // OwnerCellId = 0x0100 (the indoor cell), TargetCellId = 0xFFFF (faces outdoor). // From outside, walking through this portal enters OwnerCellId. var portal = PortalPlane.FromVertices( new Vector3(45f, 40f, 45f), new Vector3(45f, 60f, 45f), new Vector3(45f, 60f, 55f), targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal }, worldOffsetX: 0f, worldOffsetY: 0f); // Walk from X=40 (outdoor) through X=45 (portal) to X=50 (indoor). var result = engine.Resolve( new Vector3(40f, 50f, 50f), cellId: 0x0001, delta: new Vector3(10f, 0f, 0f), stepUpHeight: 5f); // Should have transitioned to indoor cell 0x0100. Assert.Equal(0x0100u, result.CellId & 0xFFFFu); Assert.True(result.IsOnGround); } [Fact] public void Resolve_IndoorThroughExitPortal_TransitionsToOutdoor() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); var cellVerts = new Dictionary { [0] = new(40f, 40f, 50f), [1] = new(60f, 40f, 50f), [2] = new(60f, 60f, 50f), [3] = new(40f, 60f, 50f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); // Same portal geometry — OwnerCellId = 0x0100, TargetCellId = 0xFFFF (outdoor exit). var portal = PortalPlane.FromVertices( new Vector3(45f, 40f, 45f), new Vector3(45f, 60f, 45f), new Vector3(45f, 60f, 55f), targetCellId: 0xFFFF, ownerCellId: 0x0100, flags: 0); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, new[] { portal }, worldOffsetX: 0f, worldOffsetY: 0f); // Walk from X=50 (indoor) through X=45 (portal) to X=40 (outdoor). var result = engine.Resolve( new Vector3(50f, 50f, 50f), cellId: 0x0100, delta: new Vector3(-10f, 0f, 0f), stepUpHeight: 5f); // Should have transitioned to outdoor. Assert.True((result.CellId & 0xFFFFu) < 0x0100u); Assert.True(result.IsOnGround); } [Fact] public void Resolve_LandblockBoundary_PicksAdjacentTerrain() { var engine = new PhysicsEngine(); // Landblock A: flat at Z=50, offset at X=0. var terrainA = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); engine.AddLandblock(0xA9B4FFFFu, terrainA, Array.Empty(), Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Landblock B: flat at Z=60, offset at X=192 (adjacent east). var terrainB = new TerrainSurface(FlatHeightmap(60), LinearHeightTable()); engine.AddLandblock(0xAAB4FFFFu, terrainB, Array.Empty(), Array.Empty(), worldOffsetX: 192f, worldOffsetY: 0f); // Walk from X=190 (landblock A) across to X=194 (landblock B). var result = engine.Resolve( new Vector3(190f, 96f, 50f), cellId: 0x0001, delta: new Vector3(4f, 0f, 0f), stepUpHeight: 15f); // Should be at Z=60 (landblock B's terrain) and position X≈194. Assert.Equal(60f, result.Position.Z, precision: 1); Assert.True(result.Position.X > 192f); } [Fact] public void Resolve_LeaveIndoorCell_TransitionsToOutdoor() { var engine = new PhysicsEngine(); var terrain = new TerrainSurface(FlatHeightmap(50), LinearHeightTable()); var cellVerts = new Dictionary { [0] = new(40f, 40f, 55f), [1] = new(60f, 40f, 55f), [2] = new(60f, 60f, 55f), [3] = new(40f, 60f, 55f), }; var cellPolys = new List> { new() { 0, 1, 2, 3 } }; var cell = new CellSurface(0x0100, cellVerts, cellPolys); engine.AddLandblock(0xA9B4FFFFu, terrain, new[] { cell }, Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); // Start inside the cell, walk out. var result = engine.Resolve( new Vector3(50f, 50f, 55f), cellId: 0x0100, delta: new Vector3(-20f, 0f, 0f), stepUpHeight: 10f); // Should transition back to outdoor. Assert.True(result.CellId < 0x0100u); Assert.Equal(50f, result.Position.Z, precision: 1); Assert.True(result.IsOnGround); } [Fact] public void Resolve_NoSurfaceUnderEntity_NotOnGround() { var engine = new PhysicsEngine(); // No landblocks loaded — entity is floating in void. var result = engine.Resolve( new Vector3(0f, 0f, 100f), cellId: 0x0001, delta: Vector3.Zero, stepUpHeight: 2f); Assert.False(result.IsOnGround); } }