using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; public class ShadowObjectRegistryMultiPartTests { private const uint LbId = 0xA9B40000u; private const float OffX = 0f; private const float OffY = 0f; private static IReadOnlyList DoorShapes() => new[] { new ShadowShape( GfxObjId: 0u, LocalPosition: new Vector3(0f, 0f, 0.018f), LocalRotation: Quaternion.Identity, Scale: 1.0f, CollisionType: ShadowCollisionType.Cylinder, Radius: 0.100f, CylHeight: 0.200f), new ShadowShape( GfxObjId: 0x010044B5u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity, Scale: 1.0f, CollisionType: ShadowCollisionType.BSP, Radius: 2.0f, CylHeight: 0f), new ShadowShape( GfxObjId: 0x010044B6u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity, Scale: 1.0f, CollisionType: ShadowCollisionType.BSP, Radius: 2.0f, CylHeight: 0f), new ShadowShape( GfxObjId: 0x010044B6u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity, Scale: 1.0f, CollisionType: ShadowCollisionType.BSP, Radius: 2.0f, CylHeight: 0f) }; [Fact] public void RegisterMultiPart_FourShapes_AllShareEntityId() { var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart( entityId: doorEntityId, entityWorldPos: new Vector3(132.6f, 17.1f, 94.08f), entityWorldRot: Quaternion.Identity, shapes: DoorShapes(), state: 0x10008u, flags: EntityCollisionFlags.None, worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId); int found = 0; foreach (var entry in reg.AllEntriesForDebug()) { if (entry.EntityId == doorEntityId) found++; } Assert.True(found >= 4, $"Expected at least 4 entries for door entity (one per shape); found {found}"); } [Fact] public void RegisterMultiPart_EmptyShapeList_NoOp() { var reg = new ShadowObjectRegistry(); reg.RegisterMultiPart( entityId: 0x1u, entityWorldPos: Vector3.Zero, entityWorldRot: Quaternion.Identity, shapes: System.Array.Empty(), state: 0u, flags: EntityCollisionFlags.None, worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId); Assert.Equal(0, reg.TotalRegistered); } [Fact] public void Deregister_RemovesAllParts() { var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f), Quaternion.Identity, DoorShapes(), 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); reg.Deregister(doorEntityId); Assert.Equal(0, reg.TotalRegistered); foreach (var entry in reg.AllEntriesForDebug()) Assert.NotEqual(doorEntityId, entry.EntityId); } [Fact] public void UpdatePhysicsState_PropagatesEtherealToAllParts() { var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f), Quaternion.Identity, DoorShapes(), 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); reg.UpdatePhysicsState(doorEntityId, 0x1000Cu); // 0x10008 | 0x4 int updated = 0; foreach (var entry in reg.AllEntriesForDebug()) { if (entry.EntityId != doorEntityId) continue; Assert.Equal(0x1000Cu, entry.State); updated++; } Assert.True(updated >= 4, $"Expected all parts updated, only {updated} were"); } [Fact] public void RegisterMultiPart_PartsAcrossMultipleCells_AllCellsListed() { var reg = new ShadowObjectRegistry(); // Two shapes 30m apart in X — must span two outdoor 24m cells. var shapes = new[] { new ShadowShape(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f, ShadowCollisionType.Cylinder, 1f, 2f), new ShadowShape(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f, ShadowCollisionType.Cylinder, 1f, 2f), }; reg.RegisterMultiPart(0x1u, new Vector3(12f, 12f, 50f), Quaternion.Identity, shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId); // Part 1 at world (12, 12) → cell (0,0) = LbId | 1 // Part 2 at world (42, 12) → cell (1,0) = LbId | 9 var entriesIn1 = reg.GetObjectsInCell(LbId | 1u); var entriesIn9 = reg.GetObjectsInCell(LbId | 9u); Assert.Contains(entriesIn1, e => e.EntityId == 0x1u); Assert.Contains(entriesIn9, e => e.EntityId == 0x1u); } [Fact] public void Register_SingleShapeCompat_Unchanged() { var reg = new ShadowObjectRegistry(); reg.Register(42u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); Assert.Equal(1, reg.TotalRegistered); Assert.Single(reg.GetObjectsInCell(LbId | 1u), e => e.EntityId == 42u); } [Fact] public void UpdatePosition_MovesAllPartsWithEntity() { var reg = new ShadowObjectRegistry(); const uint movingEntityId = 0xA1u; var shapes = new[] { new ShadowShape(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f, ShadowCollisionType.Cylinder, 0.5f, 1f), new ShadowShape(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f, ShadowCollisionType.Cylinder, 0.5f, 1f), }; reg.RegisterMultiPart(movingEntityId, new Vector3(10f, 10f, 50f), Quaternion.Identity, shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId); // Move entity to (50, 10, 50). Parts should be at (50, 10, 50) and (51, 10, 50). reg.UpdatePosition(movingEntityId, new Vector3(50f, 10f, 50f), Quaternion.Identity, OffX, OffY, LbId); Vector3 expectedPart0 = new(50f, 10f, 50f); Vector3 expectedPart1 = new(51f, 10f, 50f); var atNew = reg.AllEntriesForDebug().Where(e => e.EntityId == movingEntityId).ToList(); Assert.Equal(2, atNew.Count); bool found0 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart0) < 0.01f); bool found1 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart1) < 0.01f); Assert.True(found0 && found1, "Expected both parts at new world positions (50, 10, 50) and (51, 10, 50)"); } [Fact] public void Deregister_ClearsEntityShapesCache_NoStaleUpdatePositionRebuild() { // A6.P4 door fix (2026-05-24) regression: after Deregister, a stray // UpdatePosition with the same entityId must NOT resurrect the entity // via the _entityShapes path. The Deregister cleanup added in Task 4 // (which folded Task 6 into the multi-part registration commit) clears // _entityShapes[entityId] alongside the cell-list cleanup. var reg = new ShadowObjectRegistry(); const uint doorEntityId = 0x000F4244u; reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f), Quaternion.Identity, DoorShapes(), 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId); reg.Deregister(doorEntityId); // Stray UpdatePosition should be a no-op now (no entry to find AND // no _entityShapes entry to rebuild from). reg.UpdatePosition(doorEntityId, new Vector3(200f, 200f, 50f), Quaternion.Identity, OffX, OffY, LbId); Assert.Equal(0, reg.TotalRegistered); } }