using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.World.Cells; using DatReaderWriter.Enums; using DatReaderWriter.Types; using Xunit; using DatEnvCell = DatReaderWriter.DBObjs.EnvCell; using UcgCellPortal = AcDream.Core.World.Cells.CellPortal; namespace AcDream.Core.Tests.Physics; public class CellGraphMembershipTests { [Fact] public void ResolveCellId_Resolved_WritesCurrCellTrackingTheResolvedId() { var engine = new PhysicsEngine(); var cache = new PhysicsDataCache(); engine.DataCache = cache; var cs = new CellStruct { VertexArray = new VertexArray { Vertices = new Dictionary() }, Polygons = new Dictionary(), PhysicsBSP = null, }; var dat = new DatEnvCell { Flags = (DatReaderWriter.Enums.EnvCellFlags)0, CellPortals = new List(), VisibleCells = new List(), }; cache.CacheCellStruct(0xA9B40174u, dat, cs, Matrix4x4.Identity); // registers in the graph (W1) uint result = engine.ResolveCellId(new Vector3(0, 0, 0), 0.5f, 0xA9B40174u); // CurrCell tracks whatever id ResolveCellId returned (when that id is in the graph). Assert.NotNull(cache.CellGraph.CurrCell); Assert.Equal(result, cache.CellGraph.CurrCell!.Id); Assert.Equal(0xA9B40174u, cache.CellGraph.CurrCell!.Id); } // ── UCG W2 Task 3 — stab-list doorway hysteresis tests ─────────────────── // // Geometry: indoor cell 0xA9B40175 with a CellBSP splitting plane at Y=3.5, // normal (0,-1,0). The interior is Y ≤ 3.5 (positive side of the plane). // // SphereIntersectsCellBsp with radius=0.3 fails (returns false) when: // dist = dot((0,-1,0), center) + 3.5 = 3.5 - center.Y < -(0.3 + 0.01) = -0.31 // i.e. center.Y > 3.81 // // SphereIntersectsCellBsp with expanded radius=0.3+0.2=0.5 fails when: // center.Y > 3.5 + 0.51 = 4.01 // // So the hysteresis band is Y ∈ (3.81, 4.01]: // - Strict check failed (falls through to outdoor) // - Expanded check passes (hold the indoor cell) // // Test positions: // Y=3.9 → strict fails, expanded holds → hysteresis HOLD // Y=4.5 → both fail → genuine step-out, RELEASE // // The landblock is registered at worldOffset=(0,0) so localX=worldPos.X, // localY=worldPos.Y. At X=0, Y=3.9: cx=0, cy=0 → lowCellId=1 → // outdoorCellId = 0xA9B40001. // // Retail anchor: CObjCell::find_cell_list do_not_load_cells prune // (acclient_2013_pseudo_c.txt:308829-308867). private const uint DoorwayIndoorId = 0xA9B40175u; private const uint DoorwayLbId = 0xA9B40000u; private const float SphereRadius = 0.3f; // Y at which strict check fails but expanded (margin=0.2) still holds. private static readonly Vector3 HoldPos = new(0f, 3.9f, 95f); // Y at which both strict and expanded fail (genuine step-out). private static readonly Vector3 ReleasedPos = new(0f, 4.5f, 95f); /// /// Builds the engine+cache+prevCell fixture shared by the three hysteresis tests. /// controls whether the outdoor candidate is in the stab list. /// controls whether CellGraph.CurrCell is pre-set to prev. /// Returns the engine, cache, and the EnvCell registered as the previous indoor membership. /// private static (PhysicsEngine engine, PhysicsDataCache cache, EnvCell prev) BuildDoorwayFixture(IReadOnlyList stabList, bool setCurrCell = true) { var cache = new PhysicsDataCache(); var engine = new PhysicsEngine { DataCache = cache }; // ── CellBSP: plane at Y=3.5 with normal (0,-1,0) ───────────────── // Interior is at Y ≤ 3.5 (positive-side leaf). // SphereIntersectsCellBsp(root, center, r) checks: // dist = dot((0,-1,0), center) + 3.5 = 3.5 - center.Y // if dist < -(r + 0.01) → sphere is fully outside → return false var cellBspLeaf = new CellBSPNode { Type = BSPNodeType.Leaf }; var cellBspRoot = new CellBSPNode { SplittingPlane = new Plane(new Vector3(0f, -1f, 0f), 3.5f), PosNode = cellBspLeaf, }; var cellBsp = new CellBSPTree { Root = cellBspRoot }; // ── CellPhysics registered for the indoor sphere-check branch ───── // This is what ResolveCellId's indoor branch looks up via GetCellStruct. // We set CellBSP to cellBsp so the overlap test uses it. var indoorCell = new CellPhysics { BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, WorldTransform = Matrix4x4.Identity, InverseWorldTransform = Matrix4x4.Identity, Resolved = new Dictionary(), CellBSP = cellBsp, Portals = Array.Empty(), PortalPolygons = new Dictionary(), VisibleCellIds = new System.Collections.Generic.HashSet(), }; cache.RegisterCellStructForTest(DoorwayIndoorId, indoorCell); // ── EnvCell in the CellGraph with the same ContainmentBsp ───────── // This is what the hysteresis prune reads from CellGraph.CurrCell. // We use the SAME cellBsp instance so the test geometry is consistent. var prev = new EnvCell( id: DoorwayIndoorId, worldTransform: Matrix4x4.Identity, inverseWorldTransform: Matrix4x4.Identity, localBoundsMin: new Vector3(-20f, -20f, -20f), localBoundsMax: new Vector3(20f, 20f, 20f), portals: Array.Empty(), stabList: stabList, seenOutside: true, containmentBsp: cellBsp); cache.CellGraph.Add(prev); if (setCurrCell) cache.CellGraph.CurrCell = prev; // ── Stub landblock: terrain far below, worldOffset=(0,0) ────────── // worldPos.X, worldPos.Y map directly to localX, localY. // At X=0, Y=3.9: cx=0, cy=0 → lowCellId=1 → outdoorCellId=0xA9B40001. var heights = new byte[81]; var heightTable = new float[256]; for (int i = 0; i < 256; i++) heightTable[i] = -1000f; var stubTerrain = new TerrainSurface(heights, heightTable); engine.AddLandblock( landblockId: DoorwayLbId, terrain: stubTerrain, cells: Array.Empty(), portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); return (engine, cache, prev); } /// /// UCG W2 Task 3 — HOLD (the RED→GREEN test). /// /// Position Y=3.9 is inside the hysteresis band: /// - The strict overlap check (radius=0.3) fails → indoor branch falls through to outdoor. /// - The expanded check (radius=0.3+0.2=0.5) still passes → hold the indoor cell. /// - The outdoor candidate (0xA9B40001) is NOT in the stab list → prune fires. /// /// Expected: ResolveCellId returns 0xA9B40175 (held indoor). /// Before the fix: returns 0xA9B40001 (outdoor — ping-pong side). /// After the fix: returns 0xA9B40175 (held indoor — anti-ping-pong). /// /// Retail anchor: CObjCell::find_cell_list do_not_load_cells prune /// (acclient_2013_pseudo_c.txt:308829-308867). /// [Fact] public void ResolveCellId_DoorwayHysteresis_HoldsIndoorWhenInsideMargin() { var (engine, _, _) = BuildDoorwayFixture( stabList: Array.Empty(), setCurrCell: true); uint result = engine.ResolveCellId(HoldPos, SphereRadius, DoorwayIndoorId); Assert.Equal(DoorwayIndoorId, result); } /// /// UCG W2 Task 3 — RELEASE when fully outside (genuine step-out). /// /// Position Y=4.5 exceeds the expanded margin (4.01) — both the strict /// and expanded overlap checks fail. The prune must NOT hold — the player /// genuinely stepped out. /// /// Expected: ResolveCellId returns an outdoor cell id (low 16 bits < 0x100) /// that is NOT 0xA9B40175. /// [Fact] public void ResolveCellId_DoorwayHysteresis_ReleasesWhenFullyOutside() { var (engine, _, _) = BuildDoorwayFixture( stabList: Array.Empty(), setCurrCell: true); uint result = engine.ResolveCellId(ReleasedPos, SphereRadius, DoorwayIndoorId); // Must be an outdoor cell (low 16 bits < 0x100) and not the indoor cell. Assert.NotEqual(DoorwayIndoorId, result); Assert.True((result & 0xFFFFu) < 0x100u, $"Expected an outdoor cell (low 16 < 0x100), got 0x{result:X8}"); } /// /// UCG W2 Task 3 — RELEASE when candidate is visible (stab-list gate). /// /// The prune only fires when the outdoor candidate is NOT in the previous /// indoor cell's stab list. If the candidate IS in the stab list (genuinely /// visible from the indoor cell), the transition should proceed normally — /// hysteresis must not block it. /// /// Expected: ResolveCellId returns the outdoor cell id even though Y=3.9 /// is inside the hysteresis band. /// [Fact] public void ResolveCellId_DoorwayHysteresis_ReleasesWhenCandidateIsInStabList() { // Step 1: determine the outdoor id the engine picks at Y=3.9 // by running with CurrCell=null (no hysteresis). var (engineNull, _, _) = BuildDoorwayFixture( stabList: Array.Empty(), setCurrCell: false); uint outdoorId = engineNull.ResolveCellId(HoldPos, SphereRadius, DoorwayIndoorId); // Sanity: must be outdoor (low 16 < 0x100). Assert.True((outdoorId & 0xFFFFu) < 0x100u, $"Expected outdoor fallback id (low 16 < 0x100), got 0x{outdoorId:X8}"); // Step 2: build a fresh fixture with the outdoor id IN the stab list. var (engine, _, _) = BuildDoorwayFixture( stabList: new uint[] { outdoorId }, setCurrCell: true); uint result = engine.ResolveCellId(HoldPos, SphereRadius, DoorwayIndoorId); // Stab-list gate: outdoor candidate is visible → prune must NOT hold. Assert.Equal(outdoorId, result); } }