acdream/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs
Erik d5ffb0331b feat(phys): UpdatePosition handles multi-part entities
Multi-part entities cached via RegisterMultiPart's _entityShapes now
recompose all part transforms on UpdatePosition (called when the server
broadcasts UpdatePosition (0xF748) for a moving entity). Legacy
single-shape path preserved unchanged for tests + entities that never
went through RegisterMultiPart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:21:35 +02:00

196 lines
7.4 KiB
C#

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<ShadowShape> 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<ShadowShape>(),
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)");
}
}