feat(core): add IGameState, IEvents, WorldEvents with replay-on-subscribe
Adds WorldEntitySnapshot, IGameState, IEvents abstractions; WorldEvents implements replay-on-subscribe with per-handler exception swallowing; WorldGameState tracks entities; AppPluginHost exposes all three; stubs wired in Program.cs to keep build green ahead of Task 9 live wiring. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
22f684e8c6
commit
0c0c042dca
10 changed files with 212 additions and 3 deletions
|
|
@ -4,6 +4,14 @@ namespace AcDream.App.Plugins;
|
||||||
|
|
||||||
public sealed class AppPluginHost : IPluginHost
|
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 IPluginLogger Log { get; }
|
||||||
|
public IGameState State { get; }
|
||||||
|
public IEvents Events { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,9 @@ if (string.IsNullOrWhiteSpace(datDir))
|
||||||
return 2;
|
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");
|
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||||
|
|
|
||||||
56
src/AcDream.Core/Plugins/WorldEvents.cs
Normal file
56
src/AcDream.Core/Plugins/WorldEvents.cs
Normal file
|
|
@ -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<WorldEntitySnapshot> _alreadySpawned = new();
|
||||||
|
private Action<WorldEntitySnapshot>? _subscribers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called by the host as each entity is hydrated into the world. Records the
|
||||||
|
/// snapshot for later replay and dispatches to current subscribers.
|
||||||
|
/// </summary>
|
||||||
|
public void FireEntitySpawned(WorldEntitySnapshot snapshot)
|
||||||
|
{
|
||||||
|
Action<WorldEntitySnapshot>? toNotify;
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_alreadySpawned.Add(snapshot);
|
||||||
|
toNotify = _subscribers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toNotify is null) return;
|
||||||
|
foreach (Action<WorldEntitySnapshot> handler in toNotify.GetInvocationList())
|
||||||
|
{
|
||||||
|
try { handler(snapshot); }
|
||||||
|
catch { /* plugin errors don't propagate out of event dispatch */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public event Action<WorldEntitySnapshot> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/AcDream.Core/Plugins/WorldGameState.cs
Normal file
14
src/AcDream.Core/Plugins/WorldGameState.cs
Normal file
|
|
@ -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<WorldEntitySnapshot> _entities = new();
|
||||||
|
|
||||||
|
public IReadOnlyList<WorldEntitySnapshot> Entities => _entities;
|
||||||
|
|
||||||
|
/// <summary>Called by the host as each entity is hydrated.</summary>
|
||||||
|
public void Add(WorldEntitySnapshot snapshot) => _entities.Add(snapshot);
|
||||||
|
}
|
||||||
7
src/AcDream.Plugin.Abstractions/IEvents.cs
Normal file
7
src/AcDream.Plugin.Abstractions/IEvents.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// src/AcDream.Plugin.Abstractions/IEvents.cs
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
public interface IEvents
|
||||||
|
{
|
||||||
|
event Action<WorldEntitySnapshot> EntitySpawned;
|
||||||
|
}
|
||||||
7
src/AcDream.Plugin.Abstractions/IGameState.cs
Normal file
7
src/AcDream.Plugin.Abstractions/IGameState.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// src/AcDream.Plugin.Abstractions/IGameState.cs
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
public interface IGameState
|
||||||
|
{
|
||||||
|
IReadOnlyList<WorldEntitySnapshot> Entities { get; }
|
||||||
|
}
|
||||||
|
|
@ -3,9 +3,11 @@ namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for a plugin into the acdream runtime. The surface will grow
|
/// 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IPluginHost
|
public interface IPluginHost
|
||||||
{
|
{
|
||||||
IPluginLogger Log { get; }
|
IPluginLogger Log { get; }
|
||||||
|
IGameState State { get; }
|
||||||
|
IEvents Events { get; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
Normal file
10
src/AcDream.Plugin.Abstractions/WorldEntitySnapshot.cs
Normal file
|
|
@ -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);
|
||||||
|
|
@ -28,6 +28,8 @@ public class PluginLoaderTests
|
||||||
private sealed class StubHost : IPluginHost
|
private sealed class StubHost : IPluginHost
|
||||||
{
|
{
|
||||||
public IPluginLogger Log { get; } = new StubLogger();
|
public IPluginLogger Log { get; } = new StubLogger();
|
||||||
|
public IGameState State { get; } = new StubState();
|
||||||
|
public IEvents Events { get; } = new StubEvents();
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class StubLogger : IPluginLogger
|
private sealed class StubLogger : IPluginLogger
|
||||||
|
|
@ -37,6 +39,20 @@ public class PluginLoaderTests
|
||||||
public void Error(string message, Exception? exception = null) { }
|
public void Error(string message, Exception? exception = null) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class StubState : IGameState
|
||||||
|
{
|
||||||
|
public IReadOnlyList<WorldEntitySnapshot> Entities { get; } = Array.Empty<WorldEntitySnapshot>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubEvents : IEvents
|
||||||
|
{
|
||||||
|
public event Action<WorldEntitySnapshot> EntitySpawned
|
||||||
|
{
|
||||||
|
add { }
|
||||||
|
remove { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
|
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
87
tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs
Normal file
87
tests/AcDream.Core.Tests/Plugins/WorldEventsTests.cs
Normal file
|
|
@ -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<uint>();
|
||||||
|
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<uint>();
|
||||||
|
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<uint>();
|
||||||
|
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<uint>();
|
||||||
|
Action<WorldEntitySnapshot> 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<uint>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue