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:
Erik 2026-04-10 20:29:29 +02:00
parent 22f684e8c6
commit 0c0c042dca
10 changed files with 212 additions and 3 deletions

View file

@ -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<WorldEntitySnapshot> Entities { get; } = Array.Empty<WorldEntitySnapshot>();
}
private sealed class StubEvents : IEvents
{
public event Action<WorldEntitySnapshot> EntitySpawned
{
add { }
remove { }
}
}
[Fact]
public void Load_FixtureDll_InstantiatesPluginAndCallsInitialize()
{

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