Close the kernel's four open questions
Real decisions with real code behind them, not just answers written
into the doc:
- Time and Log: both stay in the kernel, both on IPluginContext.
Log was already built this way by accident; Time (DeltaTime,
ElapsedTime, FrameCount) ships now, split into ITime (plugin-facing,
read-only) and Time (host-facing, an internal Tick(deltaTime) only
Engine.Host calls) — the same split Schedule/ISchedule already
established. The fixed-step accumulator from the original kernel
scope is explicitly NOT included: nothing exists to test it against
yet (no physics), so building it now would be exactly the kind of
untested speculative machinery this project has avoided everywhere
else. It arrives with M4, alongside the Stage.FixedUpdate it would
drive — a "FixedUpdate" stage with no real fixed-timestep semantics
behind it would be actively misleading, not just incomplete.
- Event Bus: real Publish/Subscribe/RemoveAllFrom, not events-as-
World-entities (a worse fit for GameObject's persistent identity
than for a disposable-entity pure ECS). Given a real consumer
immediately rather than shipped as an unused API: PluginHost now
publishes PluginLoaded/PluginUnloaded, and sandbox.echo subscribes
to PluginLoaded for real, cleaning up in Shutdown() via
Events.RemoveAllFrom — mirroring exactly how Schedule.RemoveAllFrom
was proven. The 200-cycle leak test in AlcUnloadTests now exercises
this cleanup path too, not just Schedule's; still green, stable
across repeated runs. GameWorld does NOT auto-publish on every
GameObject/Component change — a publish on every structural change
would tax the hot path for listeners that usually don't exist.
- Frame stages: fixed, kernel-defined, not plugin-extensible — a
stage is part of the shared vocabulary the host's loop and every
plugin rely on. Set stays {Update, Render} until FixedUpdate earns
its place alongside the accumulator in M4.
- Data-oriented fast path: left open on purpose, but with a trigger
condition instead of a deadline — revisit when a concrete system
(particles, the standing example) needs tens of thousands of
GameObjects updated per frame AND profiling, not intuition, shows
GameObject/Component overhead is the actual bottleneck.
PluginHost's constructor changed shape: takes EventBus (concrete, not
IEventBus — it needs RegisterPlugin, same reason it already took
Schedule instead of ISchedule) and ITime. NullEventBus is gone;
every call site now constructs a real EventBus. Engine.Host advances
Time each frame — real wall-clock delta in --windowed (via the
window's own Native.Time), a fixed nominal 1/60s in --headless, which
has no wall clock to measure and needs to stay deterministic anyway.
48 tests total now (40 in Engine.Kernel.Tests, 8 in
Engine.ConformanceHarness), all green on a clean build. Verified by
hand too: headless run against sandbox.echo now logs "[sandbox.echo]
observed load of 'sandbox.echo'" — the EventBus subscription actually
firing, not just compiling.
docs/kernel-contract.md's open-questions footer rewritten to record
each decision and why, plus the kernel scope table (§2) and the
IPluginContext listing (§3) updated to match what's actually built.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N
This commit is contained in:
+45
-10
@@ -53,8 +53,8 @@ nothing.
|
|||||||
| 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. |
|
| 02 | **Scheduler** | Frame stages, topological system ordering, parallel execution of systems with disjoint declared access, and debug-mode enforcement of that access — see §7. Structural changes (adding/removing a `GameObject` or `Component`) are queued and applied at the stage boundary, so a running system never sees a collection mutate under it. |
|
||||||
| 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. |
|
| 03 | **Plugin Host** | Manifest parsing, dependency resolution, ALC loading, unloading, reload. |
|
||||||
| 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. |
|
| 04 | **Service Registry** | Publishing and discovering interfaces between plugins. Control path, not the hot path. |
|
||||||
| 05 | **Event Bus** | Decoupled notifications: `GameObject` created, asset reloaded, plugin unloaded. |
|
| 05 | **Event Bus** | `Publish`/`Subscribe`, ownership-tracked and leak-safe the same way as the Scheduler's systems — see §7. `PluginHost` publishes `PluginLoaded`/`PluginUnloaded`; nothing publishes `GameObject` created/destroyed or asset-reloaded facts yet — the first because `GameWorld` doesn't touch the bus at all (a publish on every structural change would tax the hot path for listeners that usually don't exist), the second because there's no asset system yet. |
|
||||||
| 06 | **Time & Log** | Frame clock, fixed-step accumulator, logging interface. Kept minimal. |
|
| 06 | **Time & Log** | Frame clock (`DeltaTime`, `ElapsedTime`, `FrameCount`) and logging, both on `IPluginContext`. No fixed-step accumulator yet — deferred to M4, alongside the physics system it would actually drive. |
|
||||||
|
|
||||||
`GameObject.Transform` is the one field embedded directly rather than
|
`GameObject.Transform` is the one field embedded directly rather than
|
||||||
modeled as a `Component` subclass — it's a plain struct holding local
|
modeled as a `Component` subclass — it's a plain struct holding local
|
||||||
@@ -161,8 +161,9 @@ public interface IPluginContext
|
|||||||
IWorld World { get; } // data
|
IWorld World { get; } // data
|
||||||
IServiceRegistry Services { get; } // Provide<T> / Require<T>
|
IServiceRegistry Services { get; } // Provide<T> / Require<T>
|
||||||
ISchedule Schedule { get; } // systems and ordering
|
ISchedule Schedule { get; } // systems and ordering
|
||||||
IEventBus Events { get; }
|
IEventBus Events { get; } // Publish / Subscribe
|
||||||
ILogger Log { get; }
|
ILogger Log { get; }
|
||||||
|
ITime Time { get; } // DeltaTime, ElapsedTime, FrameCount
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -429,10 +430,44 @@ cost of changing course is still zero.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
Open questions to resolve before M0: whether `Time` and `Log` belong in the
|
**The kernel's open questions are resolved.** What M0 shipped without
|
||||||
kernel or as plugins; whether the Event Bus is needed at launch or whether
|
deciding, in order:
|
||||||
event-components in `World` cover its role; whether the set of frame stages
|
|
||||||
is fixed or plugin-extensible; and whether a data-oriented fast path (for
|
- **`Time` and `Log`: both in the kernel**, both on `IPluginContext`. Every
|
||||||
bulk operations like particles) is worth introducing later without
|
plugin needs logging and a frame clock; there's no realistic case for a
|
||||||
abandoning GameObject/Component for everything else. Assembly names in the
|
project wanting to swap either out per-project the way it would swap
|
||||||
examples are placeholders.
|
physics or rendering. `Log` was already built this way by the time the
|
||||||
|
question got asked explicitly — `Time` (`DeltaTime`, `ElapsedTime`,
|
||||||
|
`FrameCount`) shipped alongside closing the question, not before it.
|
||||||
|
**Explicitly still deferred:** the fixed-step accumulator the original
|
||||||
|
kernel scope named alongside the frame clock. Building it now, with no
|
||||||
|
physics system to test it against, would be untested speculative
|
||||||
|
machinery — exactly what this project has avoided everywhere else. It
|
||||||
|
arrives with M4, alongside the `Stage.FixedUpdate` it would drive.
|
||||||
|
- **Event Bus: real, not event-components in `World`.** A disposable
|
||||||
|
event-as-`GameObject` fits a pure ECS's cheap-entity model better than
|
||||||
|
ours, where `GameObject` carries persistent identity and hierarchy. A
|
||||||
|
conventional `Publish`/`Subscribe` bus is the better fit for this object
|
||||||
|
model specifically. `PluginHost` publishing `PluginLoaded` /
|
||||||
|
`PluginUnloaded` is the concrete proof it's real infrastructure, not an
|
||||||
|
API nobody calls — and `Subscribe`/`RemoveAllFrom` follow the exact
|
||||||
|
leak-safety shape `Schedule` already established (ownership tracked by
|
||||||
|
the subscribing delegate's declaring assembly), proven the same way:
|
||||||
|
`sandbox.echo` subscribes for real, and the 200-cycle leak test in
|
||||||
|
`AlcUnloadTests` now exercises that cleanup path, not just `Schedule`'s.
|
||||||
|
- **Frame stages: fixed, kernel-defined — not plugin-extensible.** A stage
|
||||||
|
is part of the shared language every plugin and the host loop rely on;
|
||||||
|
letting plugins register arbitrary custom stages would mean the host's
|
||||||
|
frame loop can no longer just call a known, closed set of `RunStage`s.
|
||||||
|
The set stays `{Update, Render}` until `FixedUpdate` arrives with M4 — no
|
||||||
|
stage gets added without something real to run in it.
|
||||||
|
- **Data-oriented fast path: still open, on purpose, with a trigger
|
||||||
|
condition instead of a deadline.** Not "undecided" the way the other
|
||||||
|
three were — deliberately not worth deciding before there's a concrete
|
||||||
|
system to decide it against. Revisit when a specific system (particles is
|
||||||
|
the standing example) needs tens of thousands of `GameObject`s updated
|
||||||
|
per frame *and* profiling — not intuition — shows `GameObject`/`Component`
|
||||||
|
overhead is the actual bottleneck. Until then, an early decision here
|
||||||
|
would be optimizing against a guess.
|
||||||
|
|
||||||
|
Assembly names in the examples are placeholders.
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public sealed class EchoPlugin : IPlugin
|
|||||||
ctx.Schedule.Add(Stage.Update, Tick)
|
ctx.Schedule.Add(Stage.Update, Tick)
|
||||||
.Writes<Ping>();
|
.Writes<Ping>();
|
||||||
|
|
||||||
|
// A real cross-ALC subscription, not just a system — this is what
|
||||||
|
// makes the 200-cycle leak test in AlcUnloadTests actually prove
|
||||||
|
// EventBus.RemoveAllFrom works, the same way registering Tick
|
||||||
|
// above proves it for Schedule.RemoveAllFrom.
|
||||||
|
ctx.Events.Subscribe<PluginLoaded>(OnPluginLoaded);
|
||||||
|
|
||||||
// There's no scene format yet (that's M2) — nothing else will ever
|
// There's no scene format yet (that's M2) — nothing else will ever
|
||||||
// put a GameObject in front of `engine run --headless`, so this
|
// put a GameObject in front of `engine run --headless`, so this
|
||||||
// deliberately-a-test-fixture plugin seeds its own. A real plugin
|
// deliberately-a-test-fixture plugin seeds its own. A real plugin
|
||||||
@@ -30,6 +36,7 @@ public sealed class EchoPlugin : IPlugin
|
|||||||
public void Shutdown(IPluginContext ctx)
|
public void Shutdown(IPluginContext ctx)
|
||||||
{
|
{
|
||||||
ctx.Schedule.RemoveAllFrom("sandbox.echo");
|
ctx.Schedule.RemoveAllFrom("sandbox.echo");
|
||||||
|
ctx.Events.RemoveAllFrom("sandbox.echo");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void Tick(IWorld world)
|
static void Tick(IWorld world)
|
||||||
@@ -37,4 +44,7 @@ public sealed class EchoPlugin : IPlugin
|
|||||||
foreach (var go in world.Query<Ping>())
|
foreach (var go in world.Query<Ping>())
|
||||||
go.GetComponent<Ping>()!.Count++;
|
go.GetComponent<Ping>()!.Count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void OnPluginLoaded(PluginLoaded evt) =>
|
||||||
|
Console.WriteLine($"[sandbox.echo] observed load of '{evt.PluginId}'");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ if (projectPath is null || pluginsPath is null)
|
|||||||
var world = new GameWorld();
|
var world = new GameWorld();
|
||||||
var schedule = new Schedule();
|
var schedule = new Schedule();
|
||||||
var services = new ServiceRegistry();
|
var services = new ServiceRegistry();
|
||||||
var host = new PluginHost(world, services, schedule, new NullEventBus());
|
var events = new EventBus();
|
||||||
|
var time = new Time();
|
||||||
|
var host = new PluginHost(world, services, schedule, events, time);
|
||||||
|
|
||||||
IReadOnlyList<string> loaded;
|
IReadOnlyList<string> loaded;
|
||||||
try
|
try
|
||||||
@@ -164,12 +166,17 @@ if (windowed)
|
|||||||
});
|
});
|
||||||
|
|
||||||
var frameCount = 0;
|
var frameCount = 0;
|
||||||
|
var lastTime = window!.Native.Time;
|
||||||
|
|
||||||
while (!window!.IsClosing)
|
while (!window.IsClosing)
|
||||||
{
|
{
|
||||||
frameCount++;
|
frameCount++;
|
||||||
window.Native.DoEvents();
|
window.Native.DoEvents();
|
||||||
|
|
||||||
|
var currentTime = window.Native.Time;
|
||||||
|
time.Tick((float)(currentTime - lastTime));
|
||||||
|
lastTime = currentTime;
|
||||||
|
|
||||||
if (window.IsClosing)
|
if (window.IsClosing)
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -244,8 +251,17 @@ if (windowed)
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// No wall clock to measure — headless runs as fast as the CPU allows,
|
||||||
|
// not once per real 1/60s. A fixed nominal delta keeps ITime.DeltaTime
|
||||||
|
// meaningful for systems that use it, and keeps headless runs
|
||||||
|
// deterministic, which --windowed's real wall-clock delta can't be.
|
||||||
|
const float headlessDeltaTime = 1f / 60f;
|
||||||
|
|
||||||
for (var frame = 0; frame < frames; frame++)
|
for (var frame = 0; frame < frames; frame++)
|
||||||
|
{
|
||||||
|
time.Tick(headlessDeltaTime);
|
||||||
schedule.RunStage(Stage.Update, world);
|
schedule.RunStage(Stage.Update, world);
|
||||||
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Ran {frames} update frame(s).");
|
Console.WriteLine($"Ran {frames} update frame(s).");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Engine.Kernel.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frame clock. See docs/kernel-contract.md §2.
|
||||||
|
///
|
||||||
|
/// No fixed-step accumulator yet — that was in the original kernel scope
|
||||||
|
/// but nothing consumes it: building it now, with no physics system to
|
||||||
|
/// test it against, would be exactly the kind of untested speculative
|
||||||
|
/// machinery this project has avoided everywhere else. It arrives with
|
||||||
|
/// M4, alongside the Stage.FixedUpdate it would drive — a "FixedUpdate"
|
||||||
|
/// stage without a real fixed-timestep accumulator behind it would be
|
||||||
|
/// actively misleading, not just incomplete.
|
||||||
|
/// </summary>
|
||||||
|
public interface ITime
|
||||||
|
{
|
||||||
|
/// <summary>Seconds since the previous frame's Update stage.</summary>
|
||||||
|
float DeltaTime { get; }
|
||||||
|
|
||||||
|
/// <summary>Seconds since the engine started.</summary>
|
||||||
|
double ElapsedTime { get; }
|
||||||
|
|
||||||
|
int FrameCount { get; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Engine.Kernel.Diagnostics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read-only to plugins (ITime); advanced by whoever owns the frame loop —
|
||||||
|
/// Engine.Host today, headless or windowed — via <see cref="Tick"/>, which
|
||||||
|
/// isn't on the interface. Same split as Schedule/ISchedule: the host gets
|
||||||
|
/// the extra, plugin-facing code doesn't.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class Time : ITime
|
||||||
|
{
|
||||||
|
public float DeltaTime { get; private set; }
|
||||||
|
public double ElapsedTime { get; private set; }
|
||||||
|
public int FrameCount { get; private set; }
|
||||||
|
|
||||||
|
public void Tick(float deltaTime)
|
||||||
|
{
|
||||||
|
DeltaTime = deltaTime;
|
||||||
|
ElapsedTime += deltaTime;
|
||||||
|
FrameCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ownership tracked the same way Schedule tracks systems: by the
|
||||||
|
/// subscribing delegate's declaring assembly, not a string tag per
|
||||||
|
/// subscription. See <see cref="RegisterPlugin"/> and
|
||||||
|
/// Engine.Kernel.Scheduling.Schedule.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EventBus : IEventBus
|
||||||
|
{
|
||||||
|
private readonly Dictionary<Type, List<Delegate>> _handlers = [];
|
||||||
|
private readonly Dictionary<string, Assembly> _pluginAssemblies = [];
|
||||||
|
|
||||||
|
/// <summary>Called by PluginHost right after loading a plugin's
|
||||||
|
/// implementation assembly, before Configure() runs — mirrors
|
||||||
|
/// Schedule.RegisterPlugin exactly.</summary>
|
||||||
|
internal void RegisterPlugin(string pluginId, Assembly implementationAssembly)
|
||||||
|
=> _pluginAssemblies[pluginId] = implementationAssembly;
|
||||||
|
|
||||||
|
public void Publish<TEvent>(TEvent evt) where TEvent : notnull
|
||||||
|
{
|
||||||
|
if (!_handlers.TryGetValue(typeof(TEvent), out var handlers) || handlers.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Snapshot: a handler subscribing or unsubscribing during dispatch
|
||||||
|
// must not corrupt the in-progress iteration.
|
||||||
|
foreach (var handler in handlers.ToArray())
|
||||||
|
((Action<TEvent>)handler)(evt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : notnull
|
||||||
|
{
|
||||||
|
if (!_handlers.TryGetValue(typeof(TEvent), out var handlers))
|
||||||
|
{
|
||||||
|
handlers = [];
|
||||||
|
_handlers[typeof(TEvent)] = handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlers.Add(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveAllFrom(string pluginId)
|
||||||
|
{
|
||||||
|
if (!_pluginAssemblies.Remove(pluginId, out var assembly))
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var handlers in _handlers.Values)
|
||||||
|
handlers.RemoveAll(h => h.Method.DeclaringType?.Assembly == assembly);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
namespace Engine.Kernel.Events;
|
namespace Engine.Kernel.Events;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Open question (see the footer of docs/kernel-contract.md): whether this
|
/// Decoupled notifications: a publisher fires a fact with no fixed
|
||||||
/// is needed at launch at all, or whether event-components in World cover
|
/// consumer at design time. See the "two channels" — well, three, counting
|
||||||
/// its role. Left as a bare marker so IPluginContext compiles — no API
|
/// this — table in docs/kernel-contract.md §2.
|
||||||
/// decided yet.
|
///
|
||||||
|
/// Deliberately not "events as World entities": disposable event
|
||||||
|
/// GameObjects would need something to create and clean them up every
|
||||||
|
/// frame, which fits a pure-ECS model better than GameObject/Component's
|
||||||
|
/// persistent-identity one. A real pub/sub bus is the better fit here.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface IEventBus
|
public interface IEventBus
|
||||||
{
|
{
|
||||||
|
void Publish<TEvent>(TEvent evt) where TEvent : notnull;
|
||||||
|
|
||||||
|
/// <summary>No matching Unsubscribe — same shape as ISchedule, which
|
||||||
|
/// only offers bulk removal by plugin id, not per-system removal.
|
||||||
|
/// Call RemoveAllFrom your own plugin id in Shutdown(), or a forgotten
|
||||||
|
/// subscription pins your ALC exactly the way a forgotten system
|
||||||
|
/// does — see §4.</summary>
|
||||||
|
void Subscribe<TEvent>(Action<TEvent> handler) where TEvent : notnull;
|
||||||
|
|
||||||
|
void RemoveAllFrom(string pluginId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Engine.Kernel.Events;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// IEventBus has no members yet — see the open question in
|
|
||||||
/// docs/kernel-contract.md's footer. Nothing to implement until it does.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class NullEventBus : IEventBus;
|
|
||||||
@@ -13,4 +13,5 @@ public interface IPluginContext
|
|||||||
ISchedule Schedule { get; } // systems and ordering
|
ISchedule Schedule { get; } // systems and ordering
|
||||||
IEventBus Events { get; }
|
IEventBus Events { get; }
|
||||||
ILogger Log { get; }
|
ILogger Log { get; }
|
||||||
|
ITime Time { get; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ internal sealed class PluginContext(
|
|||||||
IWorld world,
|
IWorld world,
|
||||||
IServiceRegistry services,
|
IServiceRegistry services,
|
||||||
ISchedule schedule,
|
ISchedule schedule,
|
||||||
IEventBus events) : IPluginContext
|
IEventBus events,
|
||||||
|
ITime time) : IPluginContext
|
||||||
{
|
{
|
||||||
public IWorld World { get; } = world;
|
public IWorld World { get; } = world;
|
||||||
public IServiceRegistry Services { get; } = services;
|
public IServiceRegistry Services { get; } = services;
|
||||||
public ISchedule Schedule { get; } = schedule;
|
public ISchedule Schedule { get; } = schedule;
|
||||||
public IEventBus Events { get; } = events;
|
public IEventBus Events { get; } = events;
|
||||||
public ILogger Log { get; } = new ConsoleLogger(pluginId);
|
public ILogger Log { get; } = new ConsoleLogger(pluginId);
|
||||||
|
public ITime Time { get; } = time;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Engine.Kernel.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The concrete proof that EventBus is real infrastructure, not an
|
||||||
|
/// unused API — PluginHost publishes both. See docs/kernel-contract.md §2.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct PluginLoaded(string PluginId);
|
||||||
|
|
||||||
|
public readonly record struct PluginUnloaded(string PluginId);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Runtime.Loader;
|
using System.Runtime.Loader;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using Engine.Kernel.Diagnostics;
|
||||||
using Engine.Kernel.Events;
|
using Engine.Kernel.Events;
|
||||||
using Engine.Kernel.Scheduling;
|
using Engine.Kernel.Scheduling;
|
||||||
using Engine.Kernel.Services;
|
using Engine.Kernel.Services;
|
||||||
@@ -22,7 +23,12 @@ namespace Engine.Kernel.Plugins;
|
|||||||
/// will fail wherever its own code first touches them, not with a
|
/// will fail wherever its own code first touches them, not with a
|
||||||
/// host-level error.
|
/// host-level error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule schedule, IEventBus events)
|
public sealed class PluginHost(
|
||||||
|
IWorld world,
|
||||||
|
IServiceRegistry services,
|
||||||
|
Schedule schedule,
|
||||||
|
EventBus events,
|
||||||
|
ITime time)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions ManifestOptions = new()
|
private static readonly JsonSerializerOptions ManifestOptions = new()
|
||||||
{
|
{
|
||||||
@@ -64,11 +70,13 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
|
|||||||
var instance = (IPlugin)Activator.CreateInstance(pluginType)!;
|
var instance = (IPlugin)Activator.CreateInstance(pluginType)!;
|
||||||
|
|
||||||
schedule.RegisterPlugin(manifest.Id, implAssembly);
|
schedule.RegisterPlugin(manifest.Id, implAssembly);
|
||||||
var ctx = new PluginContext(manifest.Id, world, services, schedule, events);
|
events.RegisterPlugin(manifest.Id, implAssembly);
|
||||||
|
var ctx = new PluginContext(manifest.Id, world, services, schedule, events, time);
|
||||||
|
|
||||||
instance.Configure(ctx);
|
instance.Configure(ctx);
|
||||||
|
|
||||||
_loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx);
|
_loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx);
|
||||||
|
events.Publish(new PluginLoaded(manifest.Id));
|
||||||
return manifest.Id;
|
return manifest.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +128,7 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule
|
|||||||
var weakAlc = new WeakReference(loaded.Alc);
|
var weakAlc = new WeakReference(loaded.Alc);
|
||||||
loaded.Alc.Unload();
|
loaded.Alc.Unload();
|
||||||
|
|
||||||
|
events.Publish(new PluginUnloaded(pluginId));
|
||||||
return weakAlc;
|
return weakAlc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Runtime.Loader;
|
using System.Runtime.Loader;
|
||||||
|
using Engine.Kernel.Diagnostics;
|
||||||
using Engine.Kernel.Events;
|
using Engine.Kernel.Events;
|
||||||
using Engine.Kernel.Plugins;
|
using Engine.Kernel.Plugins;
|
||||||
using Engine.Kernel.Scheduling;
|
using Engine.Kernel.Scheduling;
|
||||||
@@ -22,7 +23,7 @@ public class AlcUnloadTests
|
|||||||
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo");
|
Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo");
|
||||||
|
|
||||||
private static PluginHost NewHost() =>
|
private static PluginHost NewHost() =>
|
||||||
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
|
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new EventBus(), new Time());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Plugin_Survives_200_Load_Unload_Cycles()
|
public void Plugin_Survives_200_Load_Unload_Cycles()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Engine.Kernel.Diagnostics;
|
||||||
using Engine.Kernel.Events;
|
using Engine.Kernel.Events;
|
||||||
using Engine.Kernel.Plugins;
|
using Engine.Kernel.Plugins;
|
||||||
using Engine.Kernel.Scheduling;
|
using Engine.Kernel.Scheduling;
|
||||||
@@ -28,7 +29,7 @@ public class PluginSystemTests
|
|||||||
{
|
{
|
||||||
var world = new GameWorld();
|
var world = new GameWorld();
|
||||||
var schedule = new Schedule();
|
var schedule = new Schedule();
|
||||||
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
|
var host = new PluginHost(world, new ServiceRegistry(), schedule, new EventBus(), new Time());
|
||||||
|
|
||||||
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
|
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
|
||||||
var id = host.Load(PluginDirectory);
|
var id = host.Load(PluginDirectory);
|
||||||
@@ -46,7 +47,7 @@ public class PluginSystemTests
|
|||||||
{
|
{
|
||||||
var world = new GameWorld();
|
var world = new GameWorld();
|
||||||
var schedule = new Schedule();
|
var schedule = new Schedule();
|
||||||
var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus());
|
var host = new PluginHost(world, new ServiceRegistry(), schedule, new EventBus(), new Time());
|
||||||
|
|
||||||
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
|
var ping = world.CreateGameObject("Pinger").AddComponent<Ping>();
|
||||||
var id = host.Load(PluginDirectory);
|
var id = host.Load(PluginDirectory);
|
||||||
@@ -56,4 +57,34 @@ public class PluginSystemTests
|
|||||||
|
|
||||||
Assert.Equal(0, ping.Count);
|
Assert.Equal(0, ping.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Loading_A_Plugin_Publishes_PluginLoaded_With_Its_Id()
|
||||||
|
{
|
||||||
|
var world = new GameWorld();
|
||||||
|
var events = new EventBus();
|
||||||
|
var host = new PluginHost(world, new ServiceRegistry(), new Schedule(), events, new Time());
|
||||||
|
string? observedId = null;
|
||||||
|
events.Subscribe<PluginLoaded>(e => observedId = e.PluginId);
|
||||||
|
|
||||||
|
var id = host.Load(PluginDirectory);
|
||||||
|
|
||||||
|
Assert.Equal("sandbox.echo", observedId);
|
||||||
|
host.Unload(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Unloading_A_Plugin_Publishes_PluginUnloaded_With_Its_Id()
|
||||||
|
{
|
||||||
|
var world = new GameWorld();
|
||||||
|
var events = new EventBus();
|
||||||
|
var host = new PluginHost(world, new ServiceRegistry(), new Schedule(), events, new Time());
|
||||||
|
var id = host.Load(PluginDirectory);
|
||||||
|
string? observedId = null;
|
||||||
|
events.Subscribe<PluginUnloaded>(e => observedId = e.PluginId);
|
||||||
|
|
||||||
|
host.Unload(id);
|
||||||
|
|
||||||
|
Assert.Equal("sandbox.echo", observedId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Engine.Kernel.Events;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Tests;
|
||||||
|
|
||||||
|
public class EventBusTests
|
||||||
|
{
|
||||||
|
private readonly record struct Ping(int Value);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Publish_Invokes_Every_Subscriber_For_That_Event_Type()
|
||||||
|
{
|
||||||
|
var bus = new EventBus();
|
||||||
|
var received = new List<int>();
|
||||||
|
|
||||||
|
bus.Subscribe<Ping>(p => received.Add(p.Value));
|
||||||
|
bus.Subscribe<Ping>(p => received.Add(p.Value * 10));
|
||||||
|
|
||||||
|
bus.Publish(new Ping(3));
|
||||||
|
|
||||||
|
Assert.Equal([3, 30], received);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Publish_With_No_Subscribers_Does_Not_Throw()
|
||||||
|
{
|
||||||
|
var bus = new EventBus();
|
||||||
|
|
||||||
|
var exception = Record.Exception(() => bus.Publish(new Ping(1)));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Publish_Does_Not_Invoke_Handlers_Subscribed_To_A_Different_Event_Type()
|
||||||
|
{
|
||||||
|
var bus = new EventBus();
|
||||||
|
var otherReceived = false;
|
||||||
|
|
||||||
|
bus.Subscribe<string>(_ => otherReceived = true);
|
||||||
|
|
||||||
|
bus.Publish(new Ping(1));
|
||||||
|
|
||||||
|
Assert.False(otherReceived);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void RemoveAllFrom_An_Id_With_No_Registered_Plugin_Is_A_No_Op()
|
||||||
|
{
|
||||||
|
var bus = new EventBus();
|
||||||
|
|
||||||
|
var exception = Record.Exception(() => bus.RemoveAllFrom("nothing-registered"));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Engine.Kernel.Diagnostics;
|
||||||
using Engine.Kernel.Events;
|
using Engine.Kernel.Events;
|
||||||
using Engine.Kernel.Plugins;
|
using Engine.Kernel.Plugins;
|
||||||
using Engine.Kernel.Scheduling;
|
using Engine.Kernel.Scheduling;
|
||||||
@@ -14,7 +15,7 @@ namespace Engine.Kernel.Tests;
|
|||||||
public class PluginHostTests
|
public class PluginHostTests
|
||||||
{
|
{
|
||||||
private static PluginHost NewHost() =>
|
private static PluginHost NewHost() =>
|
||||||
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new NullEventBus());
|
new(new GameWorld(), new ServiceRegistry(), new Schedule(), new EventBus(), new Time());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Load_Throws_When_The_Directory_Has_No_Manifest()
|
public void Load_Throws_When_The_Directory_Has_No_Manifest()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Engine.Kernel.Diagnostics;
|
||||||
|
|
||||||
|
namespace Engine.Kernel.Tests;
|
||||||
|
|
||||||
|
public class TimeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Starts_At_Zero()
|
||||||
|
{
|
||||||
|
var time = new Time();
|
||||||
|
|
||||||
|
Assert.Equal(0, time.DeltaTime);
|
||||||
|
Assert.Equal(0, time.ElapsedTime);
|
||||||
|
Assert.Equal(0, time.FrameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Tick_Sets_DeltaTime_And_Advances_ElapsedTime_And_FrameCount()
|
||||||
|
{
|
||||||
|
var time = new Time();
|
||||||
|
|
||||||
|
time.Tick(0.5f);
|
||||||
|
|
||||||
|
Assert.Equal(0.5f, time.DeltaTime);
|
||||||
|
Assert.Equal(0.5, time.ElapsedTime, 3);
|
||||||
|
Assert.Equal(1, time.FrameCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ElapsedTime_And_FrameCount_Accumulate_Across_Ticks()
|
||||||
|
{
|
||||||
|
var time = new Time();
|
||||||
|
|
||||||
|
time.Tick(0.5f);
|
||||||
|
time.Tick(0.25f);
|
||||||
|
|
||||||
|
Assert.Equal(0.25f, time.DeltaTime);
|
||||||
|
Assert.Equal(0.75, time.ElapsedTime, 3);
|
||||||
|
Assert.Equal(2, time.FrameCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user