feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost
Adds the plugin-facing UI registration surface (Task 9, final D.2b task). Plugins call host.Ui.AddMarkupPanel(path, binding) from Enable(); calls are buffered in BufferedUiRegistry before the GL window opens, then drained into UiHost.Root in GameWindow.OnLoad inside the RetailUi block after the first- party vitals panel. Faulty plugin markup is isolated (try/catch per panel, logged + skipped). IPluginHost.Ui added; AppPluginHost wired; StubHost in Core.Tests updated; BufferedUiRegistryTests confirms drain-once semantics. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
07bf6cbf60
commit
019350fa31
8 changed files with 102 additions and 4 deletions
|
|
@ -4,14 +4,16 @@ namespace AcDream.App.Plugins;
|
|||
|
||||
public sealed class AppPluginHost : IPluginHost
|
||||
{
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events)
|
||||
public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui)
|
||||
{
|
||||
Log = log;
|
||||
State = state;
|
||||
Events = events;
|
||||
Ui = ui;
|
||||
}
|
||||
|
||||
public IPluginLogger Log { get; }
|
||||
public IGameState State { get; }
|
||||
public IEvents Events { get; }
|
||||
public IUiRegistry Ui { get; }
|
||||
}
|
||||
|
|
|
|||
27
src/AcDream.App/Plugins/BufferedUiRegistry.cs
Normal file
27
src/AcDream.App/Plugins/BufferedUiRegistry.cs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
using System.Collections.Generic;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.Plugins;
|
||||
|
||||
/// <summary>
|
||||
/// Buffers plugin <see cref="IUiRegistry.AddMarkupPanel"/> calls (which run in
|
||||
/// Program.cs before the GL window opens) until GameWindow drains them into the
|
||||
/// UiHost tree after construction.
|
||||
/// </summary>
|
||||
public sealed class BufferedUiRegistry : IUiRegistry
|
||||
{
|
||||
public readonly record struct Pending(string MarkupPath, object Binding);
|
||||
|
||||
private readonly List<Pending> _pending = new();
|
||||
|
||||
public void AddMarkupPanel(string markupPath, object binding)
|
||||
=> _pending.Add(new Pending(markupPath, binding));
|
||||
|
||||
/// <summary>Return + clear all buffered registrations.</summary>
|
||||
public IReadOnlyList<Pending> Drain()
|
||||
{
|
||||
var copy = _pending.ToArray();
|
||||
_pending.Clear();
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
|
|
@ -23,7 +23,8 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
|
|||
|
||||
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 uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
|
||||
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry);
|
||||
|
||||
var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins");
|
||||
Log.Information("scanning plugins in {PluginsDir}", pluginsDir);
|
||||
|
|
@ -56,7 +57,7 @@ try
|
|||
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
|
||||
}
|
||||
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
|
||||
using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry);
|
||||
window.Run();
|
||||
}
|
||||
finally
|
||||
|
|
|
|||
|
|
@ -614,6 +614,8 @@ public sealed class GameWindow : IDisposable
|
|||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||||
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
||||
private AcDream.App.UI.UiHost? _uiHost;
|
||||
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
|
||||
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
|
||||
// Phase I.2: ImGui debug panel ViewModel. Lives for as long as
|
||||
// _panelHost does. Self-subscribes to CombatState in its ctor, so
|
||||
// disposing isn't required (panel host holds the only ref).
|
||||
|
|
@ -864,12 +866,14 @@ public sealed class GameWindow : IDisposable
|
|||
private int _liveAnimRejectSingleFrame;
|
||||
private int _liveAnimRejectPartFrames;
|
||||
|
||||
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents)
|
||||
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
|
||||
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
||||
{
|
||||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||||
_datDir = options.DatDir;
|
||||
_worldGameState = worldGameState;
|
||||
_worldEvents = worldEvents;
|
||||
_uiRegistry = uiRegistry;
|
||||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||||
}
|
||||
|
|
@ -1758,6 +1762,28 @@ public sealed class GameWindow : IDisposable
|
|||
var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
|
||||
_uiHost.Root.AddChild(panel);
|
||||
Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup.");
|
||||
|
||||
// Drain plugin-registered markup panels (buffered before the GL
|
||||
// window opened) into the same UiRoot tree. A faulty plugin markup
|
||||
// file is isolated — logged + skipped, never crashes the client.
|
||||
if (_uiRegistry is not null)
|
||||
{
|
||||
foreach (var p in _uiRegistry.Drain())
|
||||
{
|
||||
try
|
||||
{
|
||||
string pluginXml = System.IO.File.ReadAllText(p.MarkupPath);
|
||||
var pluginPanel = AcDream.App.UI.MarkupDocument.Build(
|
||||
pluginXml, p.Binding, ResolveChrome, controls);
|
||||
_uiHost.Root.AddChild(pluginPanel);
|
||||
Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ public interface IPluginHost
|
|||
IPluginLogger Log { get; }
|
||||
IGameState State { get; }
|
||||
IEvents Events { get; }
|
||||
IUiRegistry Ui { get; }
|
||||
}
|
||||
|
|
|
|||
14
src/AcDream.Plugin.Abstractions/IUiRegistry.cs
Normal file
14
src/AcDream.Plugin.Abstractions/IUiRegistry.cs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
namespace AcDream.Plugin.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) +
|
||||
/// a binding object exposing the data properties the markup binds to, and
|
||||
/// registers it from <c>Enable()</c>. Calls made before the GL window opens are
|
||||
/// buffered and drained once the UI host exists.
|
||||
/// </summary>
|
||||
public interface IUiRegistry
|
||||
{
|
||||
/// <param name="markupPath">Absolute path to the plugin's panel markup file.</param>
|
||||
/// <param name="binding">Object whose properties the markup's {Bindings} resolve against.</param>
|
||||
void AddMarkupPanel(string markupPath, object binding);
|
||||
}
|
||||
21
tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
Normal file
21
tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using AcDream.App.Plugins;
|
||||
|
||||
namespace AcDream.App.Tests.Plugins;
|
||||
|
||||
public class BufferedUiRegistryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Drain_YieldsBufferedRegistrationsOnceThenEmpty()
|
||||
{
|
||||
var reg = new BufferedUiRegistry();
|
||||
reg.AddMarkupPanel("a.xml", new object());
|
||||
reg.AddMarkupPanel("b.xml", new object());
|
||||
|
||||
var drained = reg.Drain();
|
||||
Assert.Equal(2, drained.Count);
|
||||
Assert.Equal("a.xml", drained[0].MarkupPath);
|
||||
Assert.Equal("b.xml", drained[1].MarkupPath);
|
||||
|
||||
Assert.Empty(reg.Drain()); // consumed
|
||||
}
|
||||
}
|
||||
|
|
@ -30,6 +30,12 @@ public class PluginLoaderTests
|
|||
public IPluginLogger Log { get; } = new StubLogger();
|
||||
public IGameState State { get; } = new StubState();
|
||||
public IEvents Events { get; } = new StubEvents();
|
||||
public IUiRegistry Ui { get; } = new StubUiRegistry();
|
||||
}
|
||||
|
||||
private sealed class StubUiRegistry : IUiRegistry
|
||||
{
|
||||
public void AddMarkupPanel(string markupPath, object binding) { }
|
||||
}
|
||||
|
||||
private sealed class StubLogger : IPluginLogger
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue