From 3c60e2cd455ccec8761da91f79e57f07e1a4ad99 Mon Sep 17 00:00:00 2001 From: Emil Date: Wed, 2 Sep 2026 04:57:38 +0300 Subject: [PATCH] Close the kernel's four open questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01N1qPfzq8TDCUMFMV3UwV5N --- docs/kernel-contract.md | 55 +++++++++++++++---- .../sandbox.echo/Sandbox.Echo/EchoPlugin.cs | 10 ++++ src/Engine.Host/Program.cs | 20 ++++++- src/Engine.Kernel/Diagnostics/ITime.cs | 23 ++++++++ src/Engine.Kernel/Diagnostics/Time.cs | 21 +++++++ src/Engine.Kernel/Events/EventBus.cs | 52 ++++++++++++++++++ src/Engine.Kernel/Events/IEventBus.cs | 22 ++++++-- src/Engine.Kernel/Events/NullEventBus.cs | 7 --- src/Engine.Kernel/Plugins/IPluginContext.cs | 1 + src/Engine.Kernel/Plugins/PluginContext.cs | 4 +- src/Engine.Kernel/Plugins/PluginEvents.cs | 9 +++ src/Engine.Kernel/Plugins/PluginHost.cs | 13 ++++- .../AlcUnloadTests.cs | 3 +- .../PluginSystemTests.cs | 35 +++++++++++- tests/Engine.Kernel.Tests/EventBusTests.cs | 55 +++++++++++++++++++ tests/Engine.Kernel.Tests/PluginHostTests.cs | 3 +- tests/Engine.Kernel.Tests/TimeTests.cs | 41 ++++++++++++++ 17 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 src/Engine.Kernel/Diagnostics/ITime.cs create mode 100644 src/Engine.Kernel/Diagnostics/Time.cs create mode 100644 src/Engine.Kernel/Events/EventBus.cs delete mode 100644 src/Engine.Kernel/Events/NullEventBus.cs create mode 100644 src/Engine.Kernel/Plugins/PluginEvents.cs create mode 100644 tests/Engine.Kernel.Tests/EventBusTests.cs create mode 100644 tests/Engine.Kernel.Tests/TimeTests.cs diff --git a/docs/kernel-contract.md b/docs/kernel-contract.md index 3bc14e8..a86a8a6 100644 --- a/docs/kernel-contract.md +++ b/docs/kernel-contract.md @@ -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. | | 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. | -| 05 | **Event Bus** | Decoupled notifications: `GameObject` created, asset reloaded, plugin unloaded. | -| 06 | **Time & Log** | Frame clock, fixed-step accumulator, logging interface. Kept minimal. | +| 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 (`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 modeled as a `Component` subclass — it's a plain struct holding local @@ -161,8 +161,9 @@ public interface IPluginContext IWorld World { get; } // data IServiceRegistry Services { get; } // Provide / Require ISchedule Schedule { get; } // systems and ordering - IEventBus Events { get; } + IEventBus Events { get; } // Publish / Subscribe 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 -kernel or as plugins; whether the Event Bus is needed at launch or whether -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 -bulk operations like particles) is worth introducing later without -abandoning GameObject/Component for everything else. Assembly names in the -examples are placeholders. +**The kernel's open questions are resolved.** What M0 shipped without +deciding, in order: + +- **`Time` and `Log`: both in the kernel**, both on `IPluginContext`. Every + plugin needs logging and a frame clock; there's no realistic case for a + project wanting to swap either out per-project the way it would swap + 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. diff --git a/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs b/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs index 5d140e9..0f6abb0 100644 --- a/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs +++ b/plugins/sandbox.echo/Sandbox.Echo/EchoPlugin.cs @@ -18,6 +18,12 @@ public sealed class EchoPlugin : IPlugin ctx.Schedule.Add(Stage.Update, Tick) .Writes(); + // 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(OnPluginLoaded); + // There's no scene format yet (that's M2) — nothing else will ever // put a GameObject in front of `engine run --headless`, so this // 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) { ctx.Schedule.RemoveAllFrom("sandbox.echo"); + ctx.Events.RemoveAllFrom("sandbox.echo"); } static void Tick(IWorld world) @@ -37,4 +44,7 @@ public sealed class EchoPlugin : IPlugin foreach (var go in world.Query()) go.GetComponent()!.Count++; } + + static void OnPluginLoaded(PluginLoaded evt) => + Console.WriteLine($"[sandbox.echo] observed load of '{evt.PluginId}'"); } diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index 9a6222b..3c785f0 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -116,7 +116,9 @@ if (projectPath is null || pluginsPath is null) var world = new GameWorld(); var schedule = new Schedule(); 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 loaded; try @@ -164,12 +166,17 @@ if (windowed) }); var frameCount = 0; + var lastTime = window!.Native.Time; - while (!window!.IsClosing) + while (!window.IsClosing) { frameCount++; window.Native.DoEvents(); + var currentTime = window.Native.Time; + time.Tick((float)(currentTime - lastTime)); + lastTime = currentTime; + if (window.IsClosing) break; @@ -244,8 +251,17 @@ if (windowed) } 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++) + { + time.Tick(headlessDeltaTime); schedule.RunStage(Stage.Update, world); + } Console.WriteLine($"Ran {frames} update frame(s)."); } diff --git a/src/Engine.Kernel/Diagnostics/ITime.cs b/src/Engine.Kernel/Diagnostics/ITime.cs new file mode 100644 index 0000000..58a2e4c --- /dev/null +++ b/src/Engine.Kernel/Diagnostics/ITime.cs @@ -0,0 +1,23 @@ +namespace Engine.Kernel.Diagnostics; + +/// +/// 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. +/// +public interface ITime +{ + /// Seconds since the previous frame's Update stage. + float DeltaTime { get; } + + /// Seconds since the engine started. + double ElapsedTime { get; } + + int FrameCount { get; } +} diff --git a/src/Engine.Kernel/Diagnostics/Time.cs b/src/Engine.Kernel/Diagnostics/Time.cs new file mode 100644 index 0000000..5dac6f4 --- /dev/null +++ b/src/Engine.Kernel/Diagnostics/Time.cs @@ -0,0 +1,21 @@ +namespace Engine.Kernel.Diagnostics; + +/// +/// Read-only to plugins (ITime); advanced by whoever owns the frame loop — +/// Engine.Host today, headless or windowed — via , which +/// isn't on the interface. Same split as Schedule/ISchedule: the host gets +/// the extra, plugin-facing code doesn't. +/// +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++; + } +} diff --git a/src/Engine.Kernel/Events/EventBus.cs b/src/Engine.Kernel/Events/EventBus.cs new file mode 100644 index 0000000..964dd61 --- /dev/null +++ b/src/Engine.Kernel/Events/EventBus.cs @@ -0,0 +1,52 @@ +using System.Reflection; + +namespace Engine.Kernel.Events; + +/// +/// Ownership tracked the same way Schedule tracks systems: by the +/// subscribing delegate's declaring assembly, not a string tag per +/// subscription. See and +/// Engine.Kernel.Scheduling.Schedule. +/// +public sealed class EventBus : IEventBus +{ + private readonly Dictionary> _handlers = []; + private readonly Dictionary _pluginAssemblies = []; + + /// Called by PluginHost right after loading a plugin's + /// implementation assembly, before Configure() runs — mirrors + /// Schedule.RegisterPlugin exactly. + internal void RegisterPlugin(string pluginId, Assembly implementationAssembly) + => _pluginAssemblies[pluginId] = implementationAssembly; + + public void Publish(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)handler)(evt); + } + + public void Subscribe(Action 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); + } +} diff --git a/src/Engine.Kernel/Events/IEventBus.cs b/src/Engine.Kernel/Events/IEventBus.cs index a37acab..246e0aa 100644 --- a/src/Engine.Kernel/Events/IEventBus.cs +++ b/src/Engine.Kernel/Events/IEventBus.cs @@ -1,11 +1,25 @@ namespace Engine.Kernel.Events; /// -/// Open question (see the footer of docs/kernel-contract.md): whether this -/// is needed at launch at all, or whether event-components in World cover -/// its role. Left as a bare marker so IPluginContext compiles — no API -/// decided yet. +/// Decoupled notifications: a publisher fires a fact with no fixed +/// consumer at design time. See the "two channels" — well, three, counting +/// this — table in docs/kernel-contract.md §2. +/// +/// 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. /// public interface IEventBus { + void Publish(TEvent evt) where TEvent : notnull; + + /// 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. + void Subscribe(Action handler) where TEvent : notnull; + + void RemoveAllFrom(string pluginId); } diff --git a/src/Engine.Kernel/Events/NullEventBus.cs b/src/Engine.Kernel/Events/NullEventBus.cs deleted file mode 100644 index c835d79..0000000 --- a/src/Engine.Kernel/Events/NullEventBus.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Engine.Kernel.Events; - -/// -/// IEventBus has no members yet — see the open question in -/// docs/kernel-contract.md's footer. Nothing to implement until it does. -/// -public sealed class NullEventBus : IEventBus; diff --git a/src/Engine.Kernel/Plugins/IPluginContext.cs b/src/Engine.Kernel/Plugins/IPluginContext.cs index a2a7730..11bd2e8 100644 --- a/src/Engine.Kernel/Plugins/IPluginContext.cs +++ b/src/Engine.Kernel/Plugins/IPluginContext.cs @@ -13,4 +13,5 @@ public interface IPluginContext ISchedule Schedule { get; } // systems and ordering IEventBus Events { get; } ILogger Log { get; } + ITime Time { get; } } diff --git a/src/Engine.Kernel/Plugins/PluginContext.cs b/src/Engine.Kernel/Plugins/PluginContext.cs index 38d8688..6ddf5e5 100644 --- a/src/Engine.Kernel/Plugins/PluginContext.cs +++ b/src/Engine.Kernel/Plugins/PluginContext.cs @@ -14,11 +14,13 @@ internal sealed class PluginContext( IWorld world, IServiceRegistry services, ISchedule schedule, - IEventBus events) : IPluginContext + IEventBus events, + ITime time) : IPluginContext { public IWorld World { get; } = world; public IServiceRegistry Services { get; } = services; public ISchedule Schedule { get; } = schedule; public IEventBus Events { get; } = events; public ILogger Log { get; } = new ConsoleLogger(pluginId); + public ITime Time { get; } = time; } diff --git a/src/Engine.Kernel/Plugins/PluginEvents.cs b/src/Engine.Kernel/Plugins/PluginEvents.cs new file mode 100644 index 0000000..5fe1e3c --- /dev/null +++ b/src/Engine.Kernel/Plugins/PluginEvents.cs @@ -0,0 +1,9 @@ +namespace Engine.Kernel.Plugins; + +/// +/// The concrete proof that EventBus is real infrastructure, not an +/// unused API — PluginHost publishes both. See docs/kernel-contract.md §2. +/// +public readonly record struct PluginLoaded(string PluginId); + +public readonly record struct PluginUnloaded(string PluginId); diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs index bcf5dd0..eb5f4a4 100644 --- a/src/Engine.Kernel/Plugins/PluginHost.cs +++ b/src/Engine.Kernel/Plugins/PluginHost.cs @@ -1,6 +1,7 @@ using System.Reflection; using System.Runtime.Loader; using System.Text.Json; +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Scheduling; using Engine.Kernel.Services; @@ -22,7 +23,12 @@ namespace Engine.Kernel.Plugins; /// will fail wherever its own code first touches them, not with a /// host-level error. /// -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() { @@ -64,11 +70,13 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule var instance = (IPlugin)Activator.CreateInstance(pluginType)!; 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); _loaded[manifest.Id] = new LoadedPlugin(manifest, alc, instance, ctx); + events.Publish(new PluginLoaded(manifest.Id)); return manifest.Id; } @@ -120,6 +128,7 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule var weakAlc = new WeakReference(loaded.Alc); loaded.Alc.Unload(); + events.Publish(new PluginUnloaded(pluginId)); return weakAlc; } diff --git a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs index fab7870..dd8a1e0 100644 --- a/tests/Engine.ConformanceHarness/AlcUnloadTests.cs +++ b/tests/Engine.ConformanceHarness/AlcUnloadTests.cs @@ -1,4 +1,5 @@ using System.Runtime.Loader; +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; @@ -22,7 +23,7 @@ public class AlcUnloadTests Path.Combine(AppContext.BaseDirectory, "plugins", "sandbox.echo"); 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] public void Plugin_Survives_200_Load_Unload_Cycles() diff --git a/tests/Engine.ConformanceHarness/PluginSystemTests.cs b/tests/Engine.ConformanceHarness/PluginSystemTests.cs index e2d9e06..da78f35 100644 --- a/tests/Engine.ConformanceHarness/PluginSystemTests.cs +++ b/tests/Engine.ConformanceHarness/PluginSystemTests.cs @@ -1,3 +1,4 @@ +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; @@ -28,7 +29,7 @@ public class PluginSystemTests { var world = new GameWorld(); 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(); var id = host.Load(PluginDirectory); @@ -46,7 +47,7 @@ public class PluginSystemTests { var world = new GameWorld(); 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(); var id = host.Load(PluginDirectory); @@ -56,4 +57,34 @@ public class PluginSystemTests 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(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(e => observedId = e.PluginId); + + host.Unload(id); + + Assert.Equal("sandbox.echo", observedId); + } } diff --git a/tests/Engine.Kernel.Tests/EventBusTests.cs b/tests/Engine.Kernel.Tests/EventBusTests.cs new file mode 100644 index 0000000..32e33c5 --- /dev/null +++ b/tests/Engine.Kernel.Tests/EventBusTests.cs @@ -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(); + + bus.Subscribe(p => received.Add(p.Value)); + bus.Subscribe(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(_ => 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); + } +} diff --git a/tests/Engine.Kernel.Tests/PluginHostTests.cs b/tests/Engine.Kernel.Tests/PluginHostTests.cs index 2611ac9..274cfcf 100644 --- a/tests/Engine.Kernel.Tests/PluginHostTests.cs +++ b/tests/Engine.Kernel.Tests/PluginHostTests.cs @@ -1,3 +1,4 @@ +using Engine.Kernel.Diagnostics; using Engine.Kernel.Events; using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; @@ -14,7 +15,7 @@ namespace Engine.Kernel.Tests; public class PluginHostTests { 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] public void Load_Throws_When_The_Directory_Has_No_Manifest() diff --git a/tests/Engine.Kernel.Tests/TimeTests.cs b/tests/Engine.Kernel.Tests/TimeTests.cs new file mode 100644 index 0000000..5f696da --- /dev/null +++ b/tests/Engine.Kernel.Tests/TimeTests.cs @@ -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); + } +}