using System.Collections.Generic; using System.Numerics; using AcDream.App.Rendering.Vfx; using AcDream.Core.Physics; using AcDream.Core.Vfx; using AcDream.Core.World; using DatReaderWriter.Types; using Xunit; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; namespace AcDream.Core.Tests.Rendering.Vfx; public sealed class EntityScriptActivatorTests { /// Recording sink so we can assert which hooks the runner fires. 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 static DatPhysicsScript BuildScript(params (double time, AnimationHook hook)[] items) { var script = new DatPhysicsScript(); foreach (var (t, h) in items) script.ScriptData.Add(new PhysicsScriptData { StartTime = t, Hook = h }); return script; } private static WorldEntity MakeEntity(uint serverGuid, Vector3 position) => new() { Id = serverGuid, ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x02000001u, Position = position, Rotation = Quaternion.Identity, MeshRefs = System.Array.Empty(), }; private record Pipeline( ParticleSystem System, ParticleHookSink Sink, PhysicsScriptRunner Runner, RecordingSink Recording); private static Pipeline BuildPipeline(params (uint id, DatPhysicsScript script)[] scripts) { var registry = new EmitterDescRegistry(); var system = new ParticleSystem(registry); var hookSink = new ParticleHookSink(system); // for activator's StopAllForEntity var recording = new RecordingSink(); // for runner's hook dispatch var table = new Dictionary(); foreach (var (id, s) in scripts) table[id] = s; var runner = new PhysicsScriptRunner( id => table.TryGetValue(id, out var s) ? s : null, recording); return new Pipeline(system, hookSink, runner, recording); } [Fact] public void OnCreate_WithDefaultScript_FiresRunnerWithEntityGuidAndPosition() { var p = BuildPipeline( (0xAAu, BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100 })))); var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0xAAu); var entity = MakeEntity(serverGuid: 0xCAFEu, position: new Vector3(1, 2, 3)); activator.OnCreate(entity); Assert.Equal(1, p.Runner.ActiveScriptCount); p.Runner.Tick(0.001f); Assert.Single(p.Recording.Calls); Assert.Equal(0xCAFEu, p.Recording.Calls[0].EntityId); Assert.Equal(new Vector3(1, 2, 3), p.Recording.Calls[0].Pos); } [Fact] public void OnCreate_WithoutDefaultScript_DoesNothing() { var p = BuildPipeline(); // no scripts registered var activator = new EntityScriptActivator(p.Runner, p.Sink, _ => 0u); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); activator.OnCreate(entity); Assert.Equal(0, p.Runner.ActiveScriptCount); Assert.Empty(p.Recording.Calls); } /// /// Persistent emitter: TotalDuration=0 and TotalParticles=0 prevent /// auto-finish; InitialParticles=1 ensures a particle spawns at t=0 /// without waiting for the Birthrate timer; Lifespan=999f keeps that /// particle alive far past the test horizon. /// private static EmitterDesc BuildPersistentEmitterDesc() => new() { DatId = 100u, Type = ParticleType.Still, EmitterKind = ParticleEmitterKind.BirthratePerSec, MaxParticles = 4, InitialParticles = 1, TotalParticles = 0, // 0 = no particle-count cap TotalDuration = 0f, // 0 = no time-based finish Lifespan = 999f, LifetimeMin = 999f, LifetimeMax = 999f, Birthrate = 0.5f, StartSize = 0.5f, EndSize = 0.5f, StartAlpha = 1f, EndAlpha = 1f, }; [Fact] public void OnCreate_SetsEntityRotationForHookOffsetTransform() { // The CreateParticleHook's Offset is in entity-local frame; the sink // needs the entity's rotation to transform it to world space. If the // activator forgets SetEntityRotation, the offset goes off in world // axes — visual symptom: portal swirls misaligned to the portal stone. // This test verifies the seed happens by checking the spawned particle's // world position matches the rotated offset, not the unrotated offset. var registry = new EmitterDescRegistry(); registry.Register(BuildPersistentEmitterDesc()); var system = new ParticleSystem(registry); var hookSink = new ParticleHookSink(system); // Hook offset = (1, 0, 0) in entity-local frame. var hookOffset = new Frame { Origin = new Vector3(1f, 0f, 0f), Orientation = Quaternion.Identity, }; var script = BuildScript( (0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = hookOffset })); var table = new Dictionary { [0xAAu] = script }; var runner = new PhysicsScriptRunner( id => table.TryGetValue(id, out var s) ? s : null, hookSink); var activator = new EntityScriptActivator(runner, hookSink, _ => 0xAAu); // Entity rotated 90° around world-Z (yaw left); local +X maps to world +Y. var entityRotation = Quaternion.CreateFromAxisAngle( Vector3.UnitZ, MathF.PI / 2f); var entity = new WorldEntity { Id = 0xCAFEu, ServerGuid = 0xCAFEu, SourceGfxObjOrSetupId = 0x02000001u, Position = Vector3.Zero, Rotation = entityRotation, MeshRefs = System.Array.Empty(), }; activator.OnCreate(entity); runner.Tick(0.001f); system.Tick(0.001f); // Find the live particle. With the rotation applied, world position of // the local-(1,0,0) offset should be approximately world-(0,1,0). Without // the rotation seed (the bug), it would be world-(1,0,0). var live = system.EnumerateLive().FirstOrDefault(); Assert.NotNull(live.Emitter); var worldPos = live.Emitter.Particles[live.Index].Position; Assert.InRange(worldPos.X, -0.01f, 0.01f); Assert.InRange(worldPos.Y, 0.99f, 1.01f); } [Fact] public void OnRemove_StopsScriptsAndEmitters() { // For this test we need the runner to dispatch into the REAL // ParticleHookSink so OnRemove's sink.StopAllForEntity has a live // emitter to kill. This is the only observable way to verify the // call had effect without subclassing the sealed sink. var registry = new EmitterDescRegistry(); registry.Register(BuildPersistentEmitterDesc()); var system = new ParticleSystem(registry); var hookSink = new ParticleHookSink(system); var script = BuildScript((0.0, new CreateParticleHook { EmitterInfoId = 100u, Offset = new Frame() })); var table = new Dictionary { [0xAAu] = script }; var runner = new PhysicsScriptRunner( id => table.TryGetValue(id, out var s) ? s : null, hookSink); // runner dispatches into real sink, not RecordingSink var activator = new EntityScriptActivator(runner, hookSink, _ => 0xAAu); var entity = MakeEntity(serverGuid: 0xCAFEu, position: Vector3.Zero); activator.OnCreate(entity); runner.Tick(0.001f); // fires the CreateParticleHook → spawns emitter Assert.True(system.ActiveEmitterCount > 0, "Setup precondition failed: emitter should be alive after the hook fires."); activator.OnRemove(0xCAFEu); Assert.Equal(0, runner.ActiveScriptCount); // sink.StopAllForEntity marks the emitter Finished; system.Tick reaps it. system.Tick(0.01f); Assert.Equal(0, system.ActiveEmitterCount); } }