using System.Collections.Generic; using System.Numerics; using AcDream.Core.Physics; using AcDream.Core.Vfx; using DatReaderWriter; using DatReaderWriter.Types; using Xunit; using DatPhysicsScript = DatReaderWriter.DBObjs.PhysicsScript; namespace AcDream.Core.Tests.Vfx; public sealed class PhysicsScriptRunnerTests { /// /// Recording sink so tests can assert each hook dispatch. /// 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 CreateParticleHook CreateHook(uint emitterInfoId) => new CreateParticleHook { EmitterInfoId = emitterInfoId }; private static PhysicsScriptRunner MakeRunner(RecordingSink sink, params (uint id, DatPhysicsScript script)[] scripts) { // Build an in-memory resolver from the script table — no DatCollection needed. var table = new Dictionary(); foreach (var (id, s) in scripts) table[id] = s; return new PhysicsScriptRunner( id => table.TryGetValue(id, out var s) ? s : null, sink); } [Fact] public void Play_UnknownScript_ReturnsFalse() { var sink = new RecordingSink(); var runner = MakeRunner(sink); // no scripts registered Assert.False(runner.Play(0xDEADBEEF, entityId: 1, anchorWorldPos: Vector3.Zero)); Assert.Empty(sink.Calls); } [Fact] public void Play_ZeroScriptId_IgnoredSilently() { var sink = new RecordingSink(); var runner = MakeRunner(sink); Assert.False(runner.Play(0, entityId: 1, anchorWorldPos: Vector3.Zero)); Assert.Equal(0, runner.ActiveScriptCount); } [Fact] public void HooksFire_InOrder_AtScheduledTimes() { var script = BuildScript( (0.0, CreateHook(100)), (0.5, CreateHook(101)), (1.0, CreateHook(102))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, script)); runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3)); runner.Tick(0.25f); Assert.Single(sink.Calls); Assert.Equal(100u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); runner.Tick(0.35f); // total 0.6 Assert.Equal(2, sink.Calls.Count); Assert.Equal(101u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId); runner.Tick(0.9f); // total 1.5 Assert.Equal(3, sink.Calls.Count); Assert.Equal(102u, ((CreateParticleHook)sink.Calls[2].Hook).EmitterInfoId.DataId); Assert.Equal(0, runner.ActiveScriptCount); // fully consumed } [Fact] public void EntityIdAndAnchor_ArePassedThrough() { var script = BuildScript((0.0, CreateHook(1))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, script)); var anchor = new Vector3(123, 45, 67); runner.Play(scriptId: 0xAA, entityId: 0xCAFE, anchorWorldPos: anchor); runner.Tick(0.1f); Assert.Single(sink.Calls); Assert.Equal(0xCAFEu, sink.Calls[0].EntityId); Assert.Equal(anchor, sink.Calls[0].Pos); } [Fact] public void Replay_SameScriptSameEntity_Replaces_DoesNotStack() { var script = BuildScript( (0.0, CreateHook(1)), (1.0, CreateHook(2))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, script)); runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero); runner.Tick(0.1f); Assert.Single(sink.Calls); // Re-play — the old instance should be replaced, not stacked. runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero); Assert.Equal(1, runner.ActiveScriptCount); runner.Tick(0.1f); Assert.Equal(2, sink.Calls.Count); // Hook 0 fires AGAIN (fresh timeline from t=0), not hook 1. Assert.Equal(1u, ((CreateParticleHook)sink.Calls[1].Hook).EmitterInfoId.DataId); } [Fact] public void Replay_DifferentEntities_BothActiveConcurrently() { var script = BuildScript((0.0, CreateHook(42))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, script)); runner.Play(scriptId: 0xAA, entityId: 0x1, anchorWorldPos: new Vector3(1, 0, 0)); runner.Play(scriptId: 0xAA, entityId: 0x2, anchorWorldPos: new Vector3(2, 0, 0)); Assert.Equal(2, runner.ActiveScriptCount); runner.Tick(0.1f); Assert.Equal(2, sink.Calls.Count); Assert.Contains(sink.Calls, c => c.EntityId == 1u); Assert.Contains(sink.Calls, c => c.EntityId == 2u); } [Fact] public void StopAllForEntity_CancelsEntityScripts_LeavesOthers() { var script = BuildScript( (0.0, CreateHook(1)), (1.0, CreateHook(2))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, script)); runner.Play(scriptId: 0xAA, entityId: 1, anchorWorldPos: Vector3.Zero); runner.Play(scriptId: 0xAA, entityId: 2, anchorWorldPos: Vector3.Zero); runner.Tick(0.1f); // both fire hook 0 Assert.Equal(2, sink.Calls.Count); runner.StopAllForEntity(1); Assert.Equal(1, runner.ActiveScriptCount); runner.Tick(2.0f); // only entity 2's script should fire hook 1 Assert.Equal(3, sink.Calls.Count); Assert.Equal(2u, sink.Calls[^1].EntityId); } [Fact] public void CallPES_NestedScript_SpawnsOnSameEntity() { var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0f })); var inner = BuildScript((0.0, CreateHook(99))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner)); runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: new Vector3(1, 2, 3)); // First tick fires the CallPES hook. Inner script gets queued to // _active but does NOT fire this tick (we iterate _active // backwards, and the inner is appended AFTER the current index) — // matches retail's linked-list insertion semantics. Inner fires // on the NEXT tick instead. runner.Tick(0.1f); Assert.Empty(sink.Calls); // CallPES handled inline, no direct sink hit Assert.Equal(1, runner.ActiveScriptCount); // inner is queued, outer done // Second tick — inner's hook at t=0 fires now. runner.Tick(0.1f); Assert.Single(sink.Calls); Assert.Equal(99u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); Assert.Equal(0x7u, sink.Calls[0].EntityId); } [Fact] public void CallPES_WithPause_DelaysSubScript() { var outer = BuildScript((0.0, new CallPESHook { PES = 0xBB, Pause = 0.5f })); var inner = BuildScript((0.0, CreateHook(99))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, outer), (0xBBu, inner)); runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero); // CallPES fires immediately, but inner script's hook is gated by Pause. runner.Tick(0.1f); Assert.Empty(sink.Calls); // inner hook waiting on Pause=0.5s runner.Tick(0.5f); // total 0.6 > 0.5 pause Assert.Single(sink.Calls); } [Fact] public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance() { var script = BuildScript( (0.0, new CallPESHook { PES = 0xAA, Pause = 30f }), (0.0, CreateHook(123))); var sink = new RecordingSink(); var runner = MakeRunner(sink, (0xAAu, script)); runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero); runner.Tick(0.1f); Assert.Single(sink.Calls); Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); Assert.Equal(1, runner.ActiveScriptCount); runner.Tick(29.8f); Assert.Single(sink.Calls); runner.Tick(0.3f); Assert.Equal(2, sink.Calls.Count); } }