diff --git a/src/AcDream.App/Plugins/AppAutomationSurface.cs b/src/AcDream.App/Plugins/AppAutomationSurface.cs index 40ef89c9..f000d31b 100644 --- a/src/AcDream.App/Plugins/AppAutomationSurface.cs +++ b/src/AcDream.App/Plugins/AppAutomationSurface.cs @@ -1,3 +1,4 @@ +using AcDream.Core.Chat; using AcDream.Core.Player; using AcDream.Core.Spells; using AcDream.Plugin.Abstractions; @@ -25,11 +26,13 @@ namespace AcDream.App.Plugins; /// /// internal sealed class AppAutomationSurface - : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IDisposable + : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat, + IDisposable { private readonly object _gate = new(); private GameRuntime? _runtime; + private RuntimeCommunicationState? _communication; private RuntimeCharacterState? _character; private RuntimeSpellCastState? _cast; private Spellbook? _spellbook; @@ -74,6 +77,7 @@ internal sealed class AppAutomationSurface public ICharacterInfo Character => this; public ISpellCatalog Spells => this; public IMagicCommands Magic => this; + public IPluginChat Chat => this; /// Bind the surface to the runtime's gameplay owners. public void Bind( @@ -90,6 +94,7 @@ internal sealed class AppAutomationSurface return; DetachLocked(); _runtime = runtime; + _communication = runtime.CommunicationOwner; _character = character; _cast = cast; _spellbook = spellbook; @@ -132,6 +137,7 @@ internal sealed class AppAutomationSurface _character = null; _cast = null; _runtime = null; + _communication = null; } private void OnSpellbookChanged() => RebuildSpellbook(); @@ -361,6 +367,22 @@ internal sealed class AppAutomationSurface return false; } + // ── IPluginChat ─────────────────────────────────────────────────────── + /// + /// Routed to retail's ClientLocal log type (0x1A) — the channel the client + /// uses for its own notices. Nothing reaches the server, so a plugin cannot + /// accidentally speak in the player's name. + /// + public void PostSystemMessage(string text) + { + if (string.IsNullOrEmpty(text)) + return; + RuntimeCommunicationState? communication; + lock (_gate) + communication = _communication; + communication?.AddText(text, RetailLogTextType.ClientLocal); + } + // ── IMagicCommands ──────────────────────────────────────────────────── public bool IsCasting { diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 11445173..6eba7668 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -894,9 +894,32 @@ public sealed class GameWindow : PublishCompositionOwner(ref _cameraPointerInput, value, "camera pointer input"); void IGameWindowContentEffectsAudioPublication.PublishDatCollection( - IDatReaderWriter value) => + IDatReaderWriter value) + { PublishCompositionOwner(ref _dats, value, "DAT collection"); + // Retail skill names for the plugin automation surface, bound here + // because this is the moment the dats exist. Reading them earlier in + // OnLoad silently produced an empty table -- the composition phase that + // publishes the collection had not run yet -- and a plugin then saw + // zero skills with no error to explain it. + if (_automation is null) + return; + if (!value.TryGet(0x0E000004u, out var skillTable) + || skillTable is null) + { + Console.Error.WriteLine( + "plugin automation: retail SkillTable 0x0E000004 missing; " + + "plugins will see unnamed skills"); + return; + } + + var names = new Dictionary(skillTable.Skills.Count); + foreach (var entry in skillTable.Skills) + names[(uint)entry.Key] = entry.Value.Name; + _automation.BindSkillNames(names); + } + void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource( IPreparedAssetSource value) => PublishCompositionOwner( @@ -1313,20 +1336,6 @@ public sealed class GameWindow : // the executable's, so the loss is visible on every surface. WindowIconLoader.Apply(_window!); - // Retail skill names for the plugin automation surface. Read here - // rather than at construction because content opens in OnLoad; a - // plugin showing "Life Magic" instead of "skill 33" needs the same - // table the character panel uses. - if (_automation is not null && _dats is not null - && _dats.TryGet(0x0E000004u, out var skillTable) - && skillTable is not null) - { - var names = new Dictionary(skillTable.Skills.Count); - foreach (var entry in skillTable.Skills) - names[(uint)entry.Key] = entry.Value.Name; - _automation.BindSkillNames(names); - } - GameWindowCompositionPipeline.Run< GameWindowPlatformResult, HostInputCameraResult, diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs index 45b119c3..c45d3727 100644 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ b/src/AcDream.App/UI/MarkupDocument.cs @@ -18,9 +18,14 @@ public static class MarkupDocument /// Object whose public properties are bound to {PropName} attributes. /// Surface id → (GL handle, width, height) for chrome sprites. /// Optional controls.ini stylesheet for the title color. + /// + /// Retail interface font. Supplied by the host so plugin panels render + /// their text through the same glyph path as authored panels; without it + /// they fall back to the development bitmap font and look foreign. + /// public static UiNineSlicePanel Build( string xml, object binding, Func resolve, - ControlsIni? style = null) + ControlsIni? style = null, UiDatFont? datFont = null) { var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup"); if (root.Name.LocalName != "panel") @@ -62,7 +67,10 @@ public static class MarkupDocument if (!string.IsNullOrEmpty(title)) { Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One; - panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4, TextColor = tc }); + panel.AddChild(new UiLabel + { + Text = title, Left = 8, Top = 4, TextColor = tc, DatFont = datFont, + }); } foreach (var el in root.Elements()) @@ -102,6 +110,7 @@ public static class MarkupDocument Left = F(el, "x"), Top = F(el, "y"), TextSource = BindString((string?)el.Attribute("text"), binding), + DatFont = datFont, }; if (el.Attribute("color") is not null) label.TextColor = Color((string?)el.Attribute("color")); @@ -133,6 +142,7 @@ public static class MarkupDocument Width = F(el, "w"), Height = F(el, "h"), Text = (string?)el.Attribute("text") ?? string.Empty, + DatFont = datFont, }; // A bound caption lets the button re-label itself (Buff / // Stop) from the same binding object. diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index b27616f1..c7ffb0ff 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -3853,7 +3853,8 @@ public sealed class RetailUiRuntime : IDisposable xml, panel.Binding, _bindings.Assets.ResolveSprite, - _bindings.Assets.Controls); + _bindings.Assets.Controls, + _bindings.Assets.DefaultFont); Host.Root.AddChild(element); _bindings.Plugins.CompleteMount(panel, Host.Root, element); Console.WriteLine($"[D.2b] plugin UI panel loaded: {panel.MarkupPath}"); diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index 9a8ba2aa..f346432f 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -78,10 +78,26 @@ public class UiLabel : UiElement /// public Func? TextSource { get; set; } + /// + /// Retail dat font. When set the label renders through the same glyph path + /// every authored panel uses, so plugin text matches the rest of the + /// interface instead of falling back to the development bitmap font. + /// + public UiDatFont? DatFont { get; set; } + + /// Two-plane glyph outline, as retail draws interface text. + public bool Outline { get; set; } = true; + public UiLabel() { ClickThrough = true; } protected override void OnDraw(UiRenderContext ctx) - => ctx.DrawString(TextSource?.Invoke() ?? Text, 0, 0, TextColor); + { + string text = TextSource?.Invoke() ?? Text; + if (DatFont is { } dat) + ctx.DrawStringDat(dat, text, 0, 0, TextColor, Outline); + else + ctx.DrawString(text, 0, 0, TextColor); + } } /// @@ -104,8 +120,26 @@ public class UiSimpleButton : UiPanel /// without the binding object touching UI objects. /// public Func? TextSource { get; set; } + + /// Retail dat font for the caption; see . + public UiDatFont? DatFont { get; set; } + + /// Two-plane glyph outline, as retail draws interface text. + public bool Outline { get; set; } = true; + public event System.Action? Click; + /// + /// Without this the button is unclickable inside any draggable window. + /// UiRoot's press handling asks the pressed widget whether it owns the + /// pointer; a widget that does not claim the press falls through to + /// "move the ancestor window", which swallows the release and never emits + /// a Click. and + /// already declare it — this one did not, which is why a markup plugin + /// panel's button did nothing while its hit-test was perfectly fine. + /// + public override bool HandlesClick => true; + public UiSimpleButton() { BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f); @@ -126,8 +160,19 @@ public class UiSimpleButton : UiPanel { base.OnDraw(ctx); string caption = TextSource?.Invoke() ?? Text; - if (caption.Length == 0 || ctx.DefaultFont is null) return; + if (caption.Length == 0) return; + if (DatFont is { } dat) + { + float datW = dat.MeasureWidth(caption); + ctx.DrawStringDat( + dat, caption, + (Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f, + TextColor, Outline); + return; + } + + if (ctx.DefaultFont is null) return; float textW = ctx.DefaultFont.MeasureWidth(caption); float tx = (Width - textW) * 0.5f; float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f; diff --git a/src/AcDream.Plugin.Abstractions/Automation.cs b/src/AcDream.Plugin.Abstractions/Automation.cs index 10e85642..ab0db948 100644 --- a/src/AcDream.Plugin.Abstractions/Automation.cs +++ b/src/AcDream.Plugin.Abstractions/Automation.cs @@ -127,6 +127,17 @@ public interface ISpellCatalog bool TryGet(uint spellId, out PluginSpellInfo info); } +/// Writing to the player's chat window. +public interface IPluginChat +{ + /// + /// Post a client-local system line, the channel retail uses for the + /// client's own notices. It is local to this client: nothing is sent to the + /// server and no other player sees it. + /// + void PostSystemMessage(string text); +} + /// Casting, with a preflight so a plugin need not guess. public interface IMagicCommands { @@ -158,6 +169,7 @@ public interface IAutomationSurface ICharacterInfo Character { get; } ISpellCatalog Spells { get; } IMagicCommands Magic { get; } + IPluginChat Chat { get; } } /// @@ -165,7 +177,7 @@ public interface IAutomationSurface /// and every command refuses, so a plugin can keep one code path. /// public sealed class NoOpAutomationSurface - : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands + : IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IPluginChat { public static NoOpAutomationSurface Instance { get; } = new(); @@ -177,6 +189,11 @@ public sealed class NoOpAutomationSurface public ICharacterInfo Character => this; public ISpellCatalog Spells => this; public IMagicCommands Magic => this; + public IPluginChat Chat => this; + + public void PostSystemMessage(string text) + { + } public bool IsInWorld => false; public uint CurrentHealth => 0; diff --git a/src/AcDream.Plugins.MossTank/MossTankPanel.cs b/src/AcDream.Plugins.MossTank/MossTankPanel.cs index 6d5adc94..82e5984a 100644 --- a/src/AcDream.Plugins.MossTank/MossTankPanel.cs +++ b/src/AcDream.Plugins.MossTank/MossTankPanel.cs @@ -94,6 +94,7 @@ internal sealed class MossTankPanel if (_running) { Stop("Stopped."); + Announce("Stopped."); return; } @@ -114,6 +115,18 @@ internal sealed class MossTankPanel ? "Checking…" : $"Buffing 0/{_plan.Count}…"; _host.Log.Info($"MossTank: pass started, {_plan.Count} buff(s) queued"); + Announce(_plan.Count == 0 + ? "Buffing — checking what needs recasting." + : $"Buffing — {_plan.Count} spell(s) to cast."); + } + + /// + /// One chat line, tagged so it reads like a client notice rather than + /// something the character said. + /// + private void Announce(string text) + { + _host.Automation.Chat.PostSystemMessage($"[MossTank] {text}"); } private void Stop(string status) @@ -163,6 +176,7 @@ internal sealed class MossTankPanel { Stop($"Stalled after {_castThisPass} cast(s)."); _host.Log.Warn("MossTank: pass stalled; stopping"); + Announce($"Stopped — no progress after {_castThisPass} cast(s)."); return; } @@ -182,6 +196,9 @@ internal sealed class MossTankPanel { Stop($"Done — {_castThisPass} cast(s)."); _host.Log.Info($"MossTank: pass complete ({_castThisPass} cast)"); + Announce(_castThisPass == 0 + ? "Already fully buffed." + : $"Finished — {_castThisPass} spell(s) cast."); return; } diff --git a/tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs b/tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs new file mode 100644 index 00000000..9211efca --- /dev/null +++ b/tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs @@ -0,0 +1,97 @@ +using AcDream.App.UI; +using Xunit; + +namespace AcDream.App.Tests.UI; + +/// +/// End-to-end click routing for a markup-built plugin panel: press and release +/// over the button, through the real dispatcher, and +/// assert the bound action ran. +/// +/// +/// Written because a MossTank panel rendered correctly in world and its Buff +/// button did nothing when clicked. Unit-testing the markup builder proved the +/// handler was bound; only driving the actual pointer path can show whether the +/// click reaches it. +/// +public class MarkupPanelClickTests +{ + private sealed class Binding + { + public int Clicks { get; private set; } + public Action Go => () => Clicks++; + public bool Shown { get; set; } = true; + public string Status => "ok"; + } + + private const string Markup = + "" + + "