using System.Collections.Generic; using AcDream.App.Streaming; using AcDream.Core.Terrain; using AcDream.Core.World; using Xunit; namespace AcDream.Core.Tests.Streaming; public class StreamingControllerTwoTierTests { [Fact] public void Tick_FirstCall_EnqueuesNearAndFarLoadsByTier() { var loads = new List<(uint Id, LandblockStreamJobKind Kind)>(); var unloads = new List(); var state = new GpuWorldState(); var ctrl = new StreamingController( enqueueLoad: (id, kind) => loads.Add((id, kind)), enqueueUnload: unloads.Add, drainCompletions: _ => System.Array.Empty(), applyTerrain: (_, _) => { }, state: state, nearRadius: 1, farRadius: 3); ctrl.Tick(observerCx: 100, observerCy: 100); int nearCount = 0, farCount = 0; foreach (var (_, kind) in loads) { if (kind == LandblockStreamJobKind.LoadNear) nearCount++; else if (kind == LandblockStreamJobKind.LoadFar) farCount++; } Assert.Equal(9, nearCount); // 3x3 inner ring (radius=1) Assert.Equal(40, farCount); // 7x7 - 3x3 outer ring (radius=3) } [Fact] public void Tick_PlayerWalksOutOfNear_ToDemoteRoutesToRemoveEntities() { // Setup: bootstrap region at (100,100) with near=1, far=3. // The bootstrap puts LB (100,100) in the near tier. // Walking 4+ east drops LB (100,100) past the near-hysteresis // threshold (NearRadius+2 = 3); ToDemote should fire. var loads = new List<(uint, LandblockStreamJobKind)>(); var unloads = new List(); var state = new GpuWorldState(); // Pre-load LB (100,100) so RemoveEntitiesFromLandblock has something // to find. The actual entity content doesn't matter for routing. var lb100 = new LoadedLandblock( (100u << 24) | (100u << 16) | 0xFFFFu, Heightmap: null!, Entities: new[] { new WorldEntity { Id = 1, SourceGfxObjOrSetupId = 0, Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity, MeshRefs = System.Array.Empty() } }); state.AddLandblock(lb100); Assert.Equal(1, state.Entities.Count); var ctrl = new StreamingController( enqueueLoad: (id, kind) => loads.Add((id, kind)), enqueueUnload: unloads.Add, drainCompletions: _ => System.Array.Empty(), applyTerrain: (_, _) => { }, state: state, nearRadius: 1, farRadius: 3); ctrl.Tick(observerCx: 100, observerCy: 100); // bootstrap loads.Clear(); // Walk 4 east — LB (100,100) is now Chebyshev distance 4 from new // center (104,100). NearRadius+2 = 3, so 4 > 3 fires the demote. ctrl.Tick(observerCx: 104, observerCy: 100); // ToDemote runs synchronously on the render thread (no enqueue). // The visible effect is RemoveEntitiesFromLandblock dropping the entity. Assert.Empty(state.Entities); // Terrain stays loaded (demote != unload). Assert.True(state.IsLoaded((100u << 24) | (100u << 16) | 0xFFFFu)); } [Fact] public void Tick_DrainingPromoted_RoutesToAddEntitiesToExisting() { var loads = new List<(uint, LandblockStreamJobKind)>(); var unloads = new List(); var state = new GpuWorldState(); // Pre-load a far-tier-style LB record (terrain only, no entities). uint lbId = 0x32320FFFu; var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty()); state.AddLandblock(lb); Assert.Empty(state.Entities); // Streamer pushes a Promoted result carrying the entity layer. var promoted = new LandblockStreamResult.Promoted( lbId, new[] { new WorldEntity { Id = 7, SourceGfxObjOrSetupId = 0, Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity, MeshRefs = System.Array.Empty() } }); var queue = new Queue(); queue.Enqueue(promoted); var ctrl = new StreamingController( enqueueLoad: (id, kind) => loads.Add((id, kind)), enqueueUnload: unloads.Add, drainCompletions: max => { var batch = new List(); while (batch.Count < max && queue.Count > 0) batch.Add(queue.Dequeue()); return batch; }, applyTerrain: (_, _) => { }, state: state, nearRadius: 2, farRadius: 2); ctrl.Tick(50, 50); // drains the Promoted result // Promoted routes to AddEntitiesToExistingLandblock — the entity is now // merged into the existing LB record. Assert.Equal(1, state.Entities.Count); Assert.Equal(7u, state.Entities[0].Id); } }