diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index dbe918f..2916724 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -4,6 +4,14 @@ namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { - public AppPluginHost(IPluginLogger log) => Log = log; + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events) + { + Log = log; + State = state; + Events = events; + } + public IPluginLogger Log { get; } + public IGameState State { get; } + public IEvents Events { get; } } diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 10b3a6b..da0f748 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -15,7 +15,9 @@ if (string.IsNullOrWhiteSpace(datDir)) return 2; } -var host = new AppPluginHost(new SerilogAdapter(Log.Logger)); +var worldGameState = new AcDream.Core.Plugins.WorldGameState(); +var worldEvents = new AcDream.Core.Plugins.WorldEvents(); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); diff --git a/src/AcDream.Core/Plugins/WorldEvents.cs b/src/AcDream.Core/Plugins/WorldEvents.cs new file mode 100644 index 0000000..086810f --- /dev/null +++ b/src/AcDream.Core/Plugins/WorldEvents.cs @@ -0,0 +1,56 @@ +// src/AcDream.Core/Plugins/WorldEvents.cs +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public sealed class WorldEvents : IEvents +{ + private readonly object _lock = new(); + private readonly List _alreadySpawned = new(); + private Action? _subscribers; + + /// + /// Called by the host as each entity is hydrated into the world. Records the + /// snapshot for later replay and dispatches to current subscribers. + /// + public void FireEntitySpawned(WorldEntitySnapshot snapshot) + { + Action? toNotify; + lock (_lock) + { + _alreadySpawned.Add(snapshot); + toNotify = _subscribers; + } + + if (toNotify is null) return; + foreach (Action handler in toNotify.GetInvocationList()) + { + try { handler(snapshot); } + catch { /* plugin errors don't propagate out of event dispatch */ } + } + } + + public event Action EntitySpawned + { + add + { + WorldEntitySnapshot[] replay; + lock (_lock) + { + _subscribers += value; + replay = _alreadySpawned.ToArray(); + } + // Replay outside the lock to avoid deadlock if a handler re-enters. + foreach (var s in replay) + { + try { value(s); } + catch { /* plugin errors don't propagate out of += */ } + } + } + remove + { + lock (_lock) + _subscribers -= value; + } + } +} diff --git a/src/AcDream.Core/Plugins/WorldGameState.cs b/src/AcDream.Core/Plugins/WorldGameState.cs new file mode 100644 index 0000000..d304827 --- /dev/null +++ b/src/AcDream.Core/Plugins/WorldGameState.cs @@ -0,0 +1,14 @@ +// src/AcDream.Core/Plugins/WorldGameState.cs +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Plugins; + +public sealed class WorldGameState : IGameState +{ + private readonly List _entities = new(); + + public IReadOnlyList Entities => _entities; + + /// Called by the host as each entity is hydrated. + public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot); +} diff --git a/src/AcDream.Plugin.Abstractions/IEvents.cs b/src/AcDream.Plugin.Abstractions/IEvents.cs new file mode 100644 index 0000000..2e498b2 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IEvents.cs @@ -0,0 +1,7 @@ +// src/AcDream.Plugin.Abstractions/IEvents.cs +namespace AcDream.Plugin.Abstractions; + +public interface IEvents +{ + event Action EntitySpawned; +} diff --git a/src/AcDream.Plugin.Abstractions/IGameState.cs b/src/AcDream.Plugin.Abstractions/IGameState.cs new file mode 100644 index 0000000..e3d640c --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/IGameState.cs @@ -0,0 +1,7 @@ +// src/AcDream.Plugin.Abstractions/IGameState.cs +namespace AcDream.Plugin.Abstractions; + +public interface IGameState +{ + IReadOnlyList Entities { get; } +} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index 755dd77..7374ea9 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -3,9 +3,11 @@ namespace AcDream.Plugin.Abstractions; /// /// Entry point for a plugin into the acdream runtime. The surface will grow -/// across phases as more systems come online. For Phase 1 only IPluginLogger is real. +/// across phases as more systems come online. /// public interface IPluginHost { IPluginLogger Log { get; } + IGameState State { get; } + IEvents Events { get; } } diff --git a/src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs b/src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs new file mode 100644 index 0000000..d47db84 --- /dev/null +++ b/src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs @@ -0,0 +1,10 @@ +// src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs +using System.Numerics; + +namespace AcDream.Plugin.Abstractions; + +public readonly record struct WorldEntitySnapshot( + uint Id, + uint SourceId, + Vector3 Position, + Quaternion Rotation); diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index 5947e55..2fdafc9 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -28,6 +28,8 @@ public class PluginLoaderTests private sealed class StubHost : IPluginHost { public IPluginLogger Log { get; } = new StubLogger(); + public IGameState State { get; } = new StubState(); + public IEvents Events { get; } = new StubEvents(); } private sealed class StubLogger : IPluginLogger @@ -37,6 +39,20 @@ public class PluginLoaderTests public void Error(string message, Exception? exception = null) { } } + private sealed class StubState : IGameState + { + public IReadOnlyList Entities { get; } = Array.Empty(); + } + + private sealed class StubEvents : IEvents + { + public event Action EntitySpawned + { + add { } + remove { } + } + } + [Fact] public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize() { diff --git a/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs b/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs new file mode 100644 index 0000000..0868b70 --- /dev/null +++ b/tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs @@ -0,0 +1,87 @@ +// tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs +using System.Numerics; +using AcDream.Core.Plugins; +using AcDream.Plugin.Abstractions; + +namespace AcDream.Core.Tests.Plugins; + +public class WorldEventsTests +{ + private static WorldEntitySnapshot S(uint id) => new(id, SourceId: 0x01000000u, Position: Vector3.Zero, Rotation: Quaternion.Identity); + + [Fact] + public void FireBeforeAnySubscriber_LateSubscribeReceivesReplay() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); + events.FireEntitySpawned(S(2)); + events.FireEntitySpawned(S(3)); + + var seen = new List(); + events.EntitySpawned += e => seen.Add(e.Id); + + Assert.Equal(new uint[] { 1, 2, 3 }, seen); + } + + [Fact] + public void FireAfterSubscribe_ReachesSubscriber() + { + var events = new WorldEvents(); + var seen = new List(); + events.EntitySpawned += e => seen.Add(e.Id); + + events.FireEntitySpawned(S(10)); + events.FireEntitySpawned(S(20)); + + Assert.Equal(new uint[] { 10, 20 }, seen); + } + + [Fact] + public void ReplayPlusLive_DeliversExactlyOnceEach() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); // pre-subscribe + + var seen = new List(); + events.EntitySpawned += e => seen.Add(e.Id); // replay fires 1 + + events.FireEntitySpawned(S(2)); // live fires 2 + + Assert.Equal(new uint[] { 1, 2 }, seen); + } + + [Fact] + public void Unsubscribe_StopsLiveDelivery() + { + var events = new WorldEvents(); + var seen = new List(); + Action handler = e => seen.Add(e.Id); + + events.EntitySpawned += handler; + events.FireEntitySpawned(S(1)); + events.EntitySpawned -= handler; + events.FireEntitySpawned(S(2)); + + Assert.Equal(new uint[] { 1 }, seen); + } + + [Fact] + public void HandlerThrowsDuringReplay_OtherReplayEntriesStillDelivered() + { + var events = new WorldEvents(); + events.FireEntitySpawned(S(1)); + events.FireEntitySpawned(S(2)); + events.FireEntitySpawned(S(3)); + + var seen = new List(); + events.EntitySpawned += e => + { + if (e.Id == 2) throw new InvalidOperationException("boom"); + seen.Add(e.Id); + }; + + // No exception propagates out of the += add; 1 and 3 were still delivered. + Assert.Contains(1u, seen); + Assert.Contains(3u, seen); + } +}