using System.Numerics; using AcDream.Core.Lighting; using Xunit; namespace AcDream.Core.Tests.Lighting; public sealed class LightManagerTests { private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true) => new LightSource { Kind = LightKind.Point, WorldPosition = pos, Range = range, IsLit = lit, OwnerId = ownerId, }; [Fact] public void Register_Unregister_TracksList() { var mgr = new LightManager(); var a = MakePoint(Vector3.Zero, 5f); var b = MakePoint(new Vector3(10, 0, 0), 5f); mgr.Register(a); mgr.Register(b); Assert.Equal(2, mgr.RegisteredCount); mgr.Unregister(a); Assert.Equal(1, mgr.RegisteredCount); } [Fact] public void Register_DuplicateInstance_Idempotent() { var mgr = new LightManager(); var light = MakePoint(Vector3.Zero, 5f); mgr.Register(light); mgr.Register(light); Assert.Equal(1, mgr.RegisteredCount); } [Fact] public void Tick_SelectsByDistance_Top8() { var mgr = new LightManager(); // 12 lights at varying distances, all with range 100 so none filter out. for (int i = 0; i < 12; i++) mgr.Register(MakePoint(new Vector3(i, 0, 0), 100f)); mgr.Tick(viewerWorldPos: Vector3.Zero); Assert.Equal(8, mgr.ActiveCount); // Top 8 should be the closest (i=0..7). foreach (var l in mgr.Active) { Assert.NotNull(l); Assert.True(l!.WorldPosition.X <= 7f); } } [Fact] public void Tick_SelectsByDistance_RegardlessOfViewerRange() { // Retail D3D-style: candidacy is distance-only (the nearest 8). A torch // lights its OWN surfaces — the shader applies the hard `d < range` cutoff // PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing // outside the range of is still selected; it lights the wall it sits on. // Replaces the old viewer-range candidacy filter that suppressed it, which // left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2 // ambient — the "dungeon lighting off" report (#133 A7). var mgr = new LightManager(); mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range mgr.Tick(viewerWorldPos: Vector3.Zero); Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface } [Fact] public void Tick_IncludesNearbyLight() { var mgr = new LightManager(); // A nearby point light is selected (distance-only candidacy; the shader // applies the per-fragment range cutoff). mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f)); mgr.Tick(viewerWorldPos: Vector3.Zero); Assert.Equal(1, mgr.ActiveCount); } [Fact] public void Tick_SunSlot0_PreservedAcrossTicks() { var mgr = new LightManager(); var sun = new LightSource { Kind = LightKind.Directional, WorldForward = -Vector3.UnitZ }; mgr.Sun = sun; mgr.Register(MakePoint(Vector3.Zero, 100f)); mgr.Tick(Vector3.Zero); Assert.Equal(2, mgr.ActiveCount); Assert.Same(sun, mgr.Active[0]); } [Fact] public void Tick_UnlitLight_Excluded() { var mgr = new LightManager(); var light = MakePoint(Vector3.Zero, 100f, lit: false); mgr.Register(light); mgr.Tick(Vector3.Zero); Assert.Equal(0, mgr.ActiveCount); // Toggle lit: should now appear. light.IsLit = true; mgr.Tick(Vector3.Zero); Assert.Equal(1, mgr.ActiveCount); } [Fact] public void UnregisterByOwner_RemovesAttachedLights() { var mgr = new LightManager(); mgr.Register(MakePoint(Vector3.Zero, 5f, ownerId: 42)); mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, ownerId: 42)); mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, ownerId: 99)); mgr.UnregisterByOwner(42); Assert.Equal(1, mgr.RegisteredCount); } [Fact] public void DistSq_UpdatedEachTick() { var mgr = new LightManager(); var light = MakePoint(new Vector3(3, 0, 4), 10f); // dist 5 mgr.Register(light); mgr.Tick(Vector3.Zero); Assert.Equal(25f, light.DistSq, 2); mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 Assert.Equal(16f, light.DistSq, 2); } }