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);
}
}