using System; using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.App.Streaming; using AcDream.Core.Physics; using AcDream.Core.Vfx; using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; using Xunit; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; namespace AcDream.Core.Tests.Streaming; /// /// Phase C.1.5b: verifies fires /// / /// from the four /// dat-hydration paths (AddLandblock, AddEntitiesToExistingLandblock, /// RemoveLandblock, RemoveEntitiesFromLandblock), and that the /// pending-bucket merge in AddLandblock does NOT double-fire for live /// entities that already had OnCreate at . /// public sealed class GpuWorldStateActivatorTests { private sealed class RecordingSink : IAnimationHookSink { public List<(uint EntityId, Vector3 Pos, AnimationHook Hook)> Calls = new(); public void OnHook(uint entityId, Vector3 worldPos, AnimationHook hook) => Calls.Add((entityId, worldPos, hook)); } private sealed record Pipeline( GpuWorldState State, PhysicsScriptRunner Runner, ParticleHookSink Sink, RecordingSink Recording); private static Pipeline BuildPipeline(uint scriptId) { var script = new DatPhysicsScript(); script.ScriptData.Add(new PhysicsScriptData { StartTime = 0.0, Hook = new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() }, }); var table = new Dictionary { [scriptId] = script }; var registry = new EmitterDescRegistry(); var system = new ParticleSystem(registry); var sink = new ParticleHookSink(system); var recording = new RecordingSink(); var runner = new PhysicsScriptRunner(id => table.TryGetValue(id, out var s) ? s : null, recording); var activator = new EntityScriptActivator(runner, sink, _ => new ScriptActivationInfo(scriptId, Array.Empty())); var state = new GpuWorldState(entityScriptActivator: activator); return new Pipeline(state, runner, sink, recording); } private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities) => new(canonicalId, new LandBlock(), entities); private static WorldEntity DatHydrated(uint id, Vector3 pos) => new() { Id = id, ServerGuid = 0u, SourceGfxObjOrSetupId = 0x02000001u, Position = pos, Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; private static WorldEntity Live(uint serverGuid, Vector3 pos) => new() { Id = serverGuid, ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x02000001u, Position = pos, Rotation = Quaternion.Identity, MeshRefs = Array.Empty(), }; [Fact] public void AddLandblock_FiresActivatorForDatHydratedEntity() { var p = BuildPipeline(scriptId: 0xAAu); var entity = DatHydrated(id: 0x40A9B401u, pos: new Vector3(1, 2, 3)); var lb = MakeStubLandblock(0xA9B4FFFFu, entity); p.State.AddLandblock(lb); // Tick fires the CreateParticleHook into RecordingSink. p.Runner.Tick(0.001f); Assert.Single(p.Recording.Calls); Assert.Equal(0x40A9B401u, p.Recording.Calls[0].EntityId); Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos); } [Fact] public void AddLandblock_DoesNotDoubleFire_OnPendingMerge() { // Live entity (ServerGuid!=0) arrives via AppendLiveEntity first — // OnCreate fires once at that point. Then AddLandblock for the // same canonical id pulls the pending entity into the loaded list. // The new fire-site MUST NOT call OnCreate again (the live entity // is filtered out by ServerGuid != 0). var p = BuildPipeline(scriptId: 0xAAu); var live = Live(serverGuid: 0xCAFEu, pos: Vector3.Zero); p.State.AppendLiveEntity(0xA9B4FFFFu, live); var emptyLb = MakeStubLandblock(0xA9B4FFFFu); p.State.AddLandblock(emptyLb); p.Runner.Tick(0.001f); Assert.Single(p.Recording.Calls); // exactly one — no double-fire Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId); } [Fact] public void RemoveLandblock_FiresOnRemoveForDatHydratedEntity() { var p = BuildPipeline(scriptId: 0xAAu); var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); var lb = MakeStubLandblock(0xA9B4FFFFu, entity); p.State.AddLandblock(lb); // Don't Tick: Play queued the script in _active immediately; ticking // would fire its single StartTime=0 hook and self-remove the script // before we can observe RemoveLandblock cleaning it up. Assert.Equal(1, p.Runner.ActiveScriptCount); p.State.RemoveLandblock(0xA9B4FFFFu); Assert.Equal(0, p.Runner.ActiveScriptCount); } [Fact] public void AddEntitiesToExistingLandblock_FiresActivatorForEachPromoted() { var p = BuildPipeline(scriptId: 0xAAu); var emptyLb = MakeStubLandblock(0xA9B4FFFFu); p.State.AddLandblock(emptyLb); var promoted = new[] { DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero), DatHydrated(id: 0x40A9B402u, pos: Vector3.UnitX), }; p.State.AddEntitiesToExistingLandblock(0xA9B4FFFFu, promoted); p.Runner.Tick(0.001f); Assert.Equal(2, p.Recording.Calls.Count); } [Fact] public void RemoveEntitiesFromLandblock_FiresOnRemoveForDatHydratedEntities() { var p = BuildPipeline(scriptId: 0xAAu); var entity = DatHydrated(id: 0x40A9B401u, pos: Vector3.Zero); var lb = MakeStubLandblock(0xA9B4FFFFu, entity); p.State.AddLandblock(lb); // Don't Tick: see comment in RemoveLandblock_FiresOnRemoveForDatHydratedEntity. Assert.Equal(1, p.Runner.ActiveScriptCount); p.State.RemoveEntitiesFromLandblock(0xA9B4FFFFu); Assert.Equal(0, p.Runner.ActiveScriptCount); } }