fix(mosstank): unclickable button, empty skill list, dev font, and chat output
Four defects from the first in-world look, three of them with a definite root cause rather than a plausible one. **The Buff button did nothing.** Not a hit-testing problem -- the pointer found the button perfectly. 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", and a window drag returns early on release without ever emitting a Click. UiButton and UiClickablePanel both override HandlesClick for exactly this reason; UiSimpleButton never did. Latent since that class was written, and invisible until it was put inside a draggable window -- which is precisely what a markup plugin panel is. Found by reproducing it headlessly through the real UiRoot dispatcher rather than by reasoning about it: MarkupPanelClickTests drives press-and-release over the button and asserts the bound action ran, with a separate test asserting the pointer finds the button at all, so a future failure says which half broke. My earlier guess -- that a modal at character select was swallowing the click -- was wrong, and the screenshot of the panel live in world disproved it. **"0 trained skills".** The skill-name table was read in OnLoad *before* GameWindowCompositionPipeline.Run, which is what publishes the DAT collection, so _dats was still null, the whole block was skipped, and the surface reported an empty skill list with nothing to explain it. Bound in PublishDatCollection instead -- the moment the data exists -- so it cannot run early again whatever the phase ordering does, and a genuinely missing SkillTable now says so. **Plugin text used the development bitmap font.** UiLabel and UiSimpleButton gained a DatFont, and MarkupDocument now takes the retail interface font from the host, so plugin panels render through the same glyph path (including retail's two-plane outline) as authored panels. **MossTank now writes to chat.** New BCL-only IPluginChat routes to retail's ClientLocal log type (0x1A) -- the channel the client uses for its own notices, local to this client, so a plugin cannot speak in the player's name. MossTank announces the start, the finish with a cast count, and a stall. Not addressed here: the cursor showing blue rather than amber. Traced but not fixed -- CursorFeedbackController picks the cursor family from combat mode, and CombatMode.Magic selects the blue Magic cursor where Default is amber. That is a combat-mode question, unrelated to this change, and worth its own look rather than a speculative fix folded in here. Solution builds clean; 14,437 tests pass on the standard hermetic lane filter, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
17ebfc434d
commit
b9674b1f1e
8 changed files with 240 additions and 22 deletions
|
|
@ -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;
|
|||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
/// <summary>Bind the surface to the runtime's gameplay owners.</summary>
|
||||
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 ───────────────────────────────────────────────────────
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<DatReaderWriter.DBObjs.SkillTable>(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<uint, string>(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<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u, out var skillTable)
|
||||
&& skillTable is not null)
|
||||
{
|
||||
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
|
||||
foreach (var entry in skillTable.Skills)
|
||||
names[(uint)entry.Key] = entry.Value.Name;
|
||||
_automation.BindSkillNames(names);
|
||||
}
|
||||
|
||||
GameWindowCompositionPipeline.Run<
|
||||
GameWindowPlatformResult<GameWindowGraphics, IInputContext>,
|
||||
HostInputCameraResult,
|
||||
|
|
|
|||
|
|
@ -18,9 +18,14 @@ public static class MarkupDocument
|
|||
/// <param name="binding">Object whose public properties are bound to <c>{PropName}</c> attributes.</param>
|
||||
/// <param name="resolve">Surface id → (GL handle, width, height) for chrome sprites.</param>
|
||||
/// <param name="style">Optional controls.ini stylesheet for the title color.</param>
|
||||
/// <param name="datFont">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public static UiNineSlicePanel Build(
|
||||
string xml, object binding, Func<uint, (uint, int, int)> 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.
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -78,10 +78,26 @@ public class UiLabel : UiElement
|
|||
/// </summary>
|
||||
public Func<string?>? TextSource { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
|
||||
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -104,8 +120,26 @@ public class UiSimpleButton : UiPanel
|
|||
/// without the binding object touching UI objects.
|
||||
/// </summary>
|
||||
public Func<string?>? TextSource { get; set; }
|
||||
|
||||
/// <summary>Retail dat font for the caption; see <see cref="UiLabel.DatFont"/>.</summary>
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
|
||||
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
||||
public bool Outline { get; set; } = true;
|
||||
|
||||
public event System.Action? Click;
|
||||
|
||||
/// <summary>
|
||||
/// 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. <see cref="UiButton"/> and <see cref="UiClickablePanel"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -127,6 +127,17 @@ public interface ISpellCatalog
|
|||
bool TryGet(uint spellId, out PluginSpellInfo info);
|
||||
}
|
||||
|
||||
/// <summary>Writing to the player's chat window.</summary>
|
||||
public interface IPluginChat
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
void PostSystemMessage(string text);
|
||||
}
|
||||
|
||||
/// <summary>Casting, with a preflight so a plugin need not guess.</summary>
|
||||
public interface IMagicCommands
|
||||
{
|
||||
|
|
@ -158,6 +169,7 @@ public interface IAutomationSurface
|
|||
ICharacterInfo Character { get; }
|
||||
ISpellCatalog Spells { get; }
|
||||
IMagicCommands Magic { get; }
|
||||
IPluginChat Chat { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -165,7 +177,7 @@ public interface IAutomationSurface
|
|||
/// and every command refuses, so a plugin can keep one code path.
|
||||
/// </summary>
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One chat line, tagged so it reads like a client notice rather than
|
||||
/// something the character said.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
97
tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs
Normal file
97
tests/AcDream.App.Tests/UI/MarkupPanelClickTests.cs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
using AcDream.App.UI;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.App.Tests.UI;
|
||||
|
||||
/// <summary>
|
||||
/// End-to-end click routing for a markup-built plugin panel: press and release
|
||||
/// over the button, through the real <see cref="UiRoot"/> dispatcher, and
|
||||
/// assert the bound action ran.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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 =
|
||||
"<panel x=\"40\" y=\"120\" w=\"360\" h=\"132\" title=\"MossTank\" visible=\"{Shown}\">"
|
||||
+ " <label x=\"12\" y=\"30\" text=\"{Status}\"/>"
|
||||
+ " <button x=\"12\" y=\"94\" w=\"108\" h=\"28\" text=\"Buff\" onclick=\"{Go}\"/>"
|
||||
+ "</panel>";
|
||||
|
||||
private static (UiRoot Root, Binding Bound) Mount()
|
||||
{
|
||||
var bound = new Binding();
|
||||
UiNineSlicePanel panel =
|
||||
MarkupDocument.Build(Markup, bound, _ => ((uint)1, 32, 32));
|
||||
var root = new UiRoot { Width = 1280, Height = 720 };
|
||||
root.AddChild(panel);
|
||||
return (root, bound);
|
||||
}
|
||||
|
||||
/// <summary>Centre of the button in root space: panel(40,120) + button(12,94) + half.</summary>
|
||||
private static (int X, int Y) ButtonCentre() => (40 + 12 + 54, 120 + 94 + 14);
|
||||
|
||||
[Fact]
|
||||
public void ClickingTheButtonRunsTheBoundAction()
|
||||
{
|
||||
var (root, bound) = Mount();
|
||||
(int x, int y) = ButtonCentre();
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, x, y);
|
||||
root.OnMouseUp(UiMouseButton.Left, x, y);
|
||||
|
||||
Assert.Equal(1, bound.Clicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThePointerFindsTheButtonAtAll()
|
||||
{
|
||||
// Separated from the click test so a failure says whether the problem is
|
||||
// hit-testing or event dispatch.
|
||||
var (root, _) = Mount();
|
||||
(int x, int y) = ButtonCentre();
|
||||
|
||||
UiElement? hit = root.Pick(x, y);
|
||||
|
||||
Assert.NotNull(hit);
|
||||
Assert.IsType<UiSimpleButton>(hit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClickingOutsideTheButtonDoesNotRunTheAction()
|
||||
{
|
||||
var (root, bound) = Mount();
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, 900, 600);
|
||||
root.OnMouseUp(UiMouseButton.Left, 900, 600);
|
||||
|
||||
Assert.Equal(0, bound.Clicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHiddenPanelSwallowsNothing()
|
||||
{
|
||||
var (root, bound) = Mount();
|
||||
bound.Shown = false;
|
||||
root.Tick(0.016, 0); // visibility source is evaluated on tick
|
||||
(int x, int y) = ButtonCentre();
|
||||
|
||||
root.OnMouseDown(UiMouseButton.Left, x, y);
|
||||
root.OnMouseUp(UiMouseButton.Left, x, y);
|
||||
|
||||
Assert.Equal(0, bound.Clicks);
|
||||
Assert.Null(root.Pick(x, y));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue