diff --git a/LinguaEngine.sln b/LinguaEngine.sln index 05fbe3f..041b44e 100644 --- a/LinguaEngine.sln +++ b/LinguaEngine.sln @@ -23,6 +23,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Kernel.Tests", "test EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.ConformanceHarness", "tests\Engine.ConformanceHarness\Engine.ConformanceHarness.csproj", "{C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.windowing", "engine.windowing", "{E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Windowing.Contracts", "plugins\engine.windowing\Engine.Windowing.Contracts\Engine.Windowing.Contracts.csproj", "{AE132341-387B-4C5F-A428-9D3AEC57F44F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Windowing", "plugins\engine.windowing\Engine.Windowing\Engine.Windowing.csproj", "{AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "engine.render", "engine.render", "{4B0CEF17-61C3-056D-753F-1CA96E0E17CD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Engine.Render", "plugins\engine.render\Engine.Render\Engine.Render.csproj", "{6D1AEAF7-9885-4557-9D7A-2B29376A34B4}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -105,6 +115,42 @@ Global {C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}.Release|x64.Build.0 = Release|Any CPU {C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}.Release|x86.ActiveCfg = Release|Any CPU {C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D}.Release|x86.Build.0 = Release|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x64.ActiveCfg = Debug|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x64.Build.0 = Debug|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x86.ActiveCfg = Debug|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Debug|x86.Build.0 = Debug|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|Any CPU.Build.0 = Release|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x64.ActiveCfg = Release|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x64.Build.0 = Release|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x86.ActiveCfg = Release|Any CPU + {AE132341-387B-4C5F-A428-9D3AEC57F44F}.Release|x86.Build.0 = Release|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x64.ActiveCfg = Debug|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x64.Build.0 = Debug|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x86.ActiveCfg = Debug|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Debug|x86.Build.0 = Debug|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|Any CPU.Build.0 = Release|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x64.ActiveCfg = Release|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x64.Build.0 = Release|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x86.ActiveCfg = Release|Any CPU + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08}.Release|x86.Build.0 = Release|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x64.Build.0 = Debug|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x86.ActiveCfg = Debug|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Debug|x86.Build.0 = Debug|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|Any CPU.Build.0 = Release|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x64.ActiveCfg = Release|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x64.Build.0 = Release|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x86.ActiveCfg = Release|Any CPU + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -117,5 +163,10 @@ Global {B0F967C4-B801-4A53-829E-689C2728553A} = {5277871D-FD82-4E0F-9994-47CB654DDE62} {D7C61E35-8805-4C28-B3D7-084D8FBC6820} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {C9725988-61CD-4EFC-A3DD-F7F42B3C4B2D} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} + {AE132341-387B-4C5F-A428-9D3AEC57F44F} = {E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053} + {AEE3E4F9-CC35-40C6-88A9-3C401F69BA08} = {E4E5DDBB-4FEB-AC72-3CEB-8E5D71B29053} + {4B0CEF17-61C3-056D-753F-1CA96E0E17CD} = {07D57EEB-2F50-60C4-C011-FE4FA775C9A8} + {6D1AEAF7-9885-4557-9D7A-2B29376A34B4} = {4B0CEF17-61C3-056D-753F-1CA96E0E17CD} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index a8586d7..1f27f09 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,15 @@ enforcement), `PluginHost` (two-ALC load/unload, verified leak-free over 200 cycles), and a headless CLI (`engine run --headless ... --dump`) — all exist and are tested. The full agent loop from [`docs/kernel-contract.md#7`](docs/kernel-contract.md#7-written-by-an-agent-not-a-human) -runs end to end. No window, no rendering, no physics yet — see the build -order (M0–M4) in [`docs/kernel-contract.md`](docs/kernel-contract.md) for -what's next. +runs end to end. + +**M1 in progress.** `engine.windowing` and a minimal `engine.render` (clear +color, no mesh yet) exist over Silk.NET, and the milestone's actual claim — +edit a plugin's code, rebuild just it, reload it while a real window stays +open, see the change with no app restart — is proven, by hand, against a +live GL context. `engine.input` and an actual drawn triangle (vs. a clear +color) are still open. No physics yet — see the build order (M0–M4) in +[`docs/kernel-contract.md`](docs/kernel-contract.md) for what's next. Design and implementation are argued over in the same place: the doc is still the thing to disagree with before code changes to match. diff --git a/docs/kernel-contract.md b/docs/kernel-contract.md index da9fcc1..3bc14e8 100644 --- a/docs/kernel-contract.md +++ b/docs/kernel-contract.md @@ -200,7 +200,7 @@ public sealed class RenderPlugin : IPlugin public void Configure(IPluginContext ctx) { // control plane: hand out an interface, take one in - var window = ctx.Services.Require(); + var window = ctx.Services.Require(); ctx.Services.Provide(new VulkanRenderer(window)); // data plane: the system queries GameObjects by component type. diff --git a/plugins/engine.render/Engine.Render/Engine.Render.csproj b/plugins/engine.render/Engine.Render/Engine.Render.csproj new file mode 100644 index 0000000..c4b1e09 --- /dev/null +++ b/plugins/engine.render/Engine.Render/Engine.Render.csproj @@ -0,0 +1,16 @@ + + + + true + + + + + + + + + + + + diff --git a/plugins/engine.render/Engine.Render/RenderPlugin.cs b/plugins/engine.render/Engine.Render/RenderPlugin.cs new file mode 100644 index 0000000..9880366 --- /dev/null +++ b/plugins/engine.render/Engine.Render/RenderPlugin.cs @@ -0,0 +1,58 @@ +using Engine.Kernel.Plugins; +using Engine.Kernel.Scheduling; +using Engine.Kernel.World; +using Engine.Windowing.Contracts; +using Silk.NET.OpenGL; + +namespace Engine.Render; + +/// +/// M1's minimal render pipeline: clears the window to a color and swaps +/// buffers, once per Render stage. No Contracts assembly — nothing here is +/// a type another plugin needs to reference yet (see the null-Contracts +/// note on PluginManifest). See M1 in docs/kernel-contract.md §8. +/// +/// This is the whole point of M1's "done when": change ClearColor, rebuild +/// just this plugin, and reload it while the window from engine.windowing +/// stays open — the color changes with no app restart. Verified by hand, +/// not by an automated test — opening a real window needs a real display, +/// which isn't something to assume of every environment this runs in. +/// +/// engine.windowing alone produces a window that never becomes visible on +/// Wayland — unlike X11, a Wayland surface with no committed buffer simply +/// isn't shown by the compositor, so an "empty" window isn't even a black +/// rectangle, it's nothing at all. This plugin's first Clear+SwapBuffers is +/// what actually makes the window appear. +/// +public sealed class RenderPlugin : IPlugin +{ + private static readonly float[] ClearColor = [0.25f, 0.55f, 0.85f, 1f]; + + private GL? _gl; + private IEngineWindow? _window; + + public void Configure(IPluginContext ctx) + { + _window = ctx.Services.Require(); + _window.Native.GLContext!.MakeCurrent(); + _gl = _window.Native.CreateOpenGL(); + + ctx.Schedule.Add(Stage.Render, Draw); + ctx.Log.Info("GL context created"); + } + + public void Shutdown(IPluginContext ctx) + { + ctx.Schedule.RemoveAllFrom("engine.render"); + _gl?.Dispose(); + _gl = null; + _window = null; + } + + private void Draw(IWorld world) + { + _gl!.ClearColor(ClearColor[0], ClearColor[1], ClearColor[2], ClearColor[3]); + _gl.Clear(ClearBufferMask.ColorBufferBit); + _window!.Native.GLContext!.SwapBuffers(); + } +} diff --git a/plugins/engine.render/plugin.json b/plugins/engine.render/plugin.json new file mode 100644 index 0000000..d8001dc --- /dev/null +++ b/plugins/engine.render/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "engine.render", + "version": "0.1.0", + "assembly": "Engine.Render.dll", + "dependsOn": { + "engine.windowing": "^0.1" + }, + "reloadable": true +} diff --git a/plugins/engine.windowing/Engine.Windowing.Contracts/Engine.Windowing.Contracts.csproj b/plugins/engine.windowing/Engine.Windowing.Contracts/Engine.Windowing.Contracts.csproj new file mode 100644 index 0000000..09d4845 --- /dev/null +++ b/plugins/engine.windowing/Engine.Windowing.Contracts/Engine.Windowing.Contracts.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/plugins/engine.windowing/Engine.Windowing.Contracts/IEngineWindow.cs b/plugins/engine.windowing/Engine.Windowing.Contracts/IEngineWindow.cs new file mode 100644 index 0000000..bb5c17c --- /dev/null +++ b/plugins/engine.windowing/Engine.Windowing.Contracts/IEngineWindow.cs @@ -0,0 +1,22 @@ +using Silk.NET.Windowing; + +namespace Engine.Windowing.Contracts; + +/// +/// Not named IWindow — Silk.NET's own windowing type already has +/// that name in this same file's scope, and living with that would be a +/// standing invitation for the exact ambiguous-name mistake that forced +/// renaming the kernel's own World to GameWorld — see +/// docs/kernel-contract.md §2. +/// +/// Exposes the real Silk.NET window directly rather than re-wrapping it: +/// GL context creation and event pumping both need it, and hiding it +/// behind another layer buys nothing at M1's stage. See M1 in +/// docs/kernel-contract.md §8. +/// +public interface IEngineWindow +{ + IWindow Native { get; } + + bool IsClosing { get; } +} diff --git a/plugins/engine.windowing/Engine.Windowing/Engine.Windowing.csproj b/plugins/engine.windowing/Engine.Windowing/Engine.Windowing.csproj new file mode 100644 index 0000000..4cb4492 --- /dev/null +++ b/plugins/engine.windowing/Engine.Windowing/Engine.Windowing.csproj @@ -0,0 +1,19 @@ + + + + + true + + + + + + + + + + + + diff --git a/plugins/engine.windowing/Engine.Windowing/SilkEngineWindow.cs b/plugins/engine.windowing/Engine.Windowing/SilkEngineWindow.cs new file mode 100644 index 0000000..32afb92 --- /dev/null +++ b/plugins/engine.windowing/Engine.Windowing/SilkEngineWindow.cs @@ -0,0 +1,11 @@ +using Engine.Windowing.Contracts; +using Silk.NET.Windowing; + +namespace Engine.Windowing; + +internal sealed class SilkEngineWindow(IWindow native) : IEngineWindow +{ + public IWindow Native { get; } = native; + + public bool IsClosing => Native.IsClosing; +} diff --git a/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs b/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs new file mode 100644 index 0000000..581eb30 --- /dev/null +++ b/plugins/engine.windowing/Engine.Windowing/WindowingPlugin.cs @@ -0,0 +1,38 @@ +using Engine.Kernel.Plugins; +using Engine.Windowing.Contracts; +using Silk.NET.Windowing; + +namespace Engine.Windowing; + +/// +/// M1's first real plugin: opens an actual window and publishes it as a +/// service. Doesn't drive the frame loop itself — Engine.Host does that, +/// pumping window events plus Schedule.RunStage each frame, same as it +/// already does for the headless case. See M1 in docs/kernel-contract.md §8. +/// +public sealed class WindowingPlugin : IPlugin +{ + private IWindow? _window; + + public void Configure(IPluginContext ctx) + { + var options = WindowOptions.Default with + { + Size = new(1280, 720), + Title = "Lingua Engine", + }; + + _window = Window.Create(options); + _window.Initialize(); + + ctx.Services.Provide(new SilkEngineWindow(_window)); + ctx.Log.Info($"window created ({options.Size.X}x{options.Size.Y})"); + } + + public void Shutdown(IPluginContext ctx) + { + ctx.Services.Revoke(); + _window?.Dispose(); + _window = null; + } +} diff --git a/plugins/engine.windowing/plugin.json b/plugins/engine.windowing/plugin.json new file mode 100644 index 0000000..a3cb891 --- /dev/null +++ b/plugins/engine.windowing/plugin.json @@ -0,0 +1,8 @@ +{ + "id": "engine.windowing", + "version": "0.1.0", + "contracts": "Engine.Windowing.Contracts.dll", + "assembly": "Engine.Windowing.dll", + "dependsOn": {}, + "reloadable": true +} diff --git a/samples/WindowDemo/project.json b/samples/WindowDemo/project.json new file mode 100644 index 0000000..427b523 --- /dev/null +++ b/samples/WindowDemo/project.json @@ -0,0 +1,8 @@ +{ + "engineVersion": "^0.1", + "plugins": [ + { "id": "engine.windowing" }, + { "id": "engine.render" } + ], + "pluginPaths": [] +} diff --git a/src/Engine.Host/Engine.Host.csproj b/src/Engine.Host/Engine.Host.csproj index 7b13f52..e1dc8ed 100644 --- a/src/Engine.Host/Engine.Host.csproj +++ b/src/Engine.Host/Engine.Host.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Engine.Host/Program.cs b/src/Engine.Host/Program.cs index 2652f40..cddbcf5 100644 --- a/src/Engine.Host/Program.cs +++ b/src/Engine.Host/Program.cs @@ -1,7 +1,15 @@ -// The runtime host — the headless half of the loop described in -// docs/kernel-contract.md §7: +// The runtime host — drives both halves of the loop described in +// docs/kernel-contract.md §7 and the M1 windowed case in §8: // // engine run --headless --plugins --project --frames [--dump ] +// engine run --windowed --plugins --project [--dump ] +// +// --windowed needs a loaded plugin that provides IEngineWindow (engine. +// windowing) — Engine.Host references that plugin's *Contracts* assembly +// directly (never its implementation, which stays dynamically loaded via +// PluginHost/ALC same as any other plugin) because driving a window-pumped +// loop is the host's job, not the kernel's: Engine.Kernel never hears about +// Silk.NET at all. // // Deliberately not implemented yet, both noted explicitly below rather than // silently accepted or rejected as gibberish: @@ -22,6 +30,7 @@ using Engine.Kernel.Plugins; using Engine.Kernel.Scheduling; using Engine.Kernel.Services; using Engine.Kernel.World; +using Engine.Windowing.Contracts; if (args.Length == 0) { @@ -46,6 +55,7 @@ string? pluginsPath = null; string? dumpPath = null; var frames = 0; var headless = false; +var windowed = false; for (var i = 1; i < args.Length; i++) { @@ -54,6 +64,9 @@ for (var i = 1; i < args.Length; i++) case "--headless": headless = true; break; + case "--windowed": + windowed = true; + break; case "--frames" when i + 1 < args.Length: frames = int.Parse(args[++i]); break; @@ -77,9 +90,10 @@ for (var i = 1; i < args.Length; i++) } } -if (!headless) +if (headless == windowed) { - Console.Error.WriteLine("Only --headless is implemented — there's no windowing plugin yet."); + Console.Error.WriteLine("Pass exactly one of --headless or --windowed."); + PrintUsage(); return 1; } @@ -92,7 +106,8 @@ if (projectPath is null || pluginsPath is null) var world = new GameWorld(); var schedule = new Schedule(); -var host = new PluginHost(world, new ServiceRegistry(), schedule, new NullEventBus()); +var services = new ServiceRegistry(); +var host = new PluginHost(world, services, schedule, new NullEventBus()); IReadOnlyList loaded; try @@ -107,10 +122,69 @@ catch (Exception ex) Console.WriteLine($"Loaded {loaded.Count} plugin(s): {string.Join(", ", loaded)}"); -for (var frame = 0; frame < frames; frame++) - schedule.RunStage(Stage.Update, world); +if (windowed) +{ + if (!services.TryRequire(out var window)) + { + Console.Error.WriteLine( + "--windowed requires a loaded plugin that provides IEngineWindow (e.g. engine.windowing)."); + return 1; + } -Console.WriteLine($"Ran {frames} update frame(s)."); + Console.WriteLine("Window open — close it to exit. Type 'r ' + Enter to reload a plugin live."); + + // Line-based, not Console.ReadKey: KeyAvailable needs a real terminal + // in raw mode and throws or misbehaves on piped/redirected stdin. A + // background reader plus a thread-safe queue works either way and + // costs nothing on the render loop's own thread. + var reloadQueue = new System.Collections.Concurrent.ConcurrentQueue(); + _ = Task.Run(() => + { + string? line; + while ((line = Console.ReadLine()) is not null) + { + var parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts is ["r", var pluginId]) + reloadQueue.Enqueue(pluginId); + } + }); + + while (!window!.IsClosing) + { + window.Native.DoEvents(); + + if (window.IsClosing) + break; + + while (reloadQueue.TryDequeue(out var pluginId)) + { + var pluginDirectory = Path.Combine(pluginsPath, pluginId); + Console.WriteLine($"Reloading '{pluginId}'..."); + try + { + host.Unload(pluginId); + host.Load(pluginDirectory); + Console.WriteLine($"Reloaded '{pluginId}'. World state and the window were untouched."); + } + catch (Exception ex) + { + Console.Error.WriteLine($"Failed to reload '{pluginId}': {ex.Message}"); + } + } + + schedule.RunStage(Stage.Update, world); + schedule.RunStage(Stage.Render, world); + } + + Console.WriteLine("Window closed."); +} +else +{ + for (var frame = 0; frame < frames; frame++) + schedule.RunStage(Stage.Update, world); + + Console.WriteLine($"Ran {frames} update frame(s)."); +} if (dumpPath is not null) { @@ -123,5 +197,9 @@ return 0; static void PrintUsage() { Console.Error.WriteLine( - "Usage: engine run --headless --plugins --project --frames [--dump ]"); + """ + Usage: + engine run --headless --plugins --project --frames [--dump ] + engine run --windowed --plugins --project [--dump ] + """); } diff --git a/src/Engine.Kernel/Plugins/PluginHost.cs b/src/Engine.Kernel/Plugins/PluginHost.cs index 9758d32..b92aa9e 100644 --- a/src/Engine.Kernel/Plugins/PluginHost.cs +++ b/src/Engine.Kernel/Plugins/PluginHost.cs @@ -149,6 +149,9 @@ public sealed class PluginHost(IWorld world, IServiceRegistry services, Schedule private static void LoadContractsIntoDefaultAlc(string pluginDirectory, PluginManifest manifest) { + if (manifest.Contracts is null) + return; + var contractsPath = Path.Combine(pluginDirectory, manifest.Contracts); var name = AssemblyName.GetAssemblyName(contractsPath).Name; diff --git a/src/Engine.Kernel/Plugins/PluginManifest.cs b/src/Engine.Kernel/Plugins/PluginManifest.cs index 7d2993a..6ee0028 100644 --- a/src/Engine.Kernel/Plugins/PluginManifest.cs +++ b/src/Engine.Kernel/Plugins/PluginManifest.cs @@ -10,8 +10,11 @@ public sealed class PluginManifest public required string Id { get; init; } public required string Version { get; init; } - /// Assembly loaded into the Default ALC. Never unloads. See §4. - public required string Contracts { get; init; } + /// Assembly loaded into the Default ALC. Never unloads. See §4. + /// Null for a plugin with no types or interfaces for anything else to + /// reference — manufacturing an empty Contracts assembly just to fill + /// this field would document nothing real. + public string? Contracts { get; init; } /// Assembly loaded into a collectible ALC. Reloadable. See §4. public required string Assembly { get; init; } diff --git a/src/Engine.Kernel/Services/IServiceRegistry.cs b/src/Engine.Kernel/Services/IServiceRegistry.cs index 85b8984..00481be 100644 --- a/src/Engine.Kernel/Services/IServiceRegistry.cs +++ b/src/Engine.Kernel/Services/IServiceRegistry.cs @@ -9,5 +9,13 @@ public interface IServiceRegistry { void Provide(T instance) where T : class; T Require() where T : class; + + /// For a caller — typically Engine.Host — that needs to know + /// whether an optional service exists without treating its absence as + /// an error. A plugin's own Configure()/Shutdown() should keep using + /// Require(): a missing dependency there is a real configuration bug, + /// not a legitimate maybe. + bool TryRequire(out T? instance) where T : class; + void Revoke() where T : class; } diff --git a/src/Engine.Kernel/Services/ServiceRegistry.cs b/src/Engine.Kernel/Services/ServiceRegistry.cs index 605fd84..3e64ca1 100644 --- a/src/Engine.Kernel/Services/ServiceRegistry.cs +++ b/src/Engine.Kernel/Services/ServiceRegistry.cs @@ -20,6 +20,18 @@ public sealed class ServiceRegistry : IServiceRegistry $"No service is registered for '{typeof(T).FullName}'."); } + public bool TryRequire(out T? instance) where T : class + { + if (_services.TryGetValue(typeof(T), out var found)) + { + instance = (T)found; + return true; + } + + instance = null; + return false; + } + // No-op if absent, deliberately: Shutdown() is expected to revoke // unconditionally, including services a partially-failed Configure() // never got around to providing. diff --git a/tests/Engine.Kernel.Tests/ServiceRegistryTests.cs b/tests/Engine.Kernel.Tests/ServiceRegistryTests.cs new file mode 100644 index 0000000..20cd3d4 --- /dev/null +++ b/tests/Engine.Kernel.Tests/ServiceRegistryTests.cs @@ -0,0 +1,89 @@ +using Engine.Kernel.Services; + +namespace Engine.Kernel.Tests; + +public class ServiceRegistryTests +{ + private interface IGreeter + { + string Greet(); + } + + private sealed class Greeter : IGreeter + { + public string Greet() => "hi"; + } + + [Fact] + public void Provide_Then_Require_Returns_The_Same_Instance() + { + var registry = new ServiceRegistry(); + var greeter = new Greeter(); + + registry.Provide(greeter); + + Assert.Same(greeter, registry.Require()); + } + + [Fact] + public void Require_Throws_When_Nothing_Is_Registered() + { + var registry = new ServiceRegistry(); + + Assert.Throws(registry.Require); + } + + [Fact] + public void Provide_Throws_When_A_Service_For_The_Type_Already_Exists() + { + var registry = new ServiceRegistry(); + registry.Provide(new Greeter()); + + Assert.Throws(() => registry.Provide(new Greeter())); + } + + [Fact] + public void TryRequire_Returns_False_And_Null_When_Nothing_Is_Registered() + { + var registry = new ServiceRegistry(); + + var found = registry.TryRequire(out var instance); + + Assert.False(found); + Assert.Null(instance); + } + + [Fact] + public void TryRequire_Returns_True_And_The_Instance_When_Registered() + { + var registry = new ServiceRegistry(); + var greeter = new Greeter(); + registry.Provide(greeter); + + var found = registry.TryRequire(out var instance); + + Assert.True(found); + Assert.Same(greeter, instance); + } + + [Fact] + public void Revoke_Is_A_No_Op_When_Nothing_Is_Registered() + { + var registry = new ServiceRegistry(); + + var exception = Record.Exception(registry.Revoke); + + Assert.Null(exception); + } + + [Fact] + public void Revoke_Removes_The_Service_So_Require_Throws_Afterward() + { + var registry = new ServiceRegistry(); + registry.Provide(new Greeter()); + + registry.Revoke(); + + Assert.Throws(registry.Require); + } +}