feat(plugins): MossTank — a self-buffing plugin, and the automation surface it needed

First consumer of acdream's plugin automation surface, and the first slice of
the VTank-class plugin milestone
(docs/research/2026-07-29-vtank-plugin-automation-requirements.md).

MossTank shows a panel with a Buff button; clicking it casts every self-buff
the character is missing, skips what is already in force at an equal or higher
tier, and refreshes what is nearly expired.

The host/plugin line is the load-bearing decision here. The host publishes
spell DATA -- family, tier, difficulty, mana, duration -- plus a cast
primitive with a preflight gate. The plugin owns the POLICY. That is the
architectural conclusion the requirements research reached: VTank's engine
lived in plugin-land, built on Decal's primitives, and baking "best buff for
skill X" into the host would start pulling the engine inward one convenience
at a time.

Why the plan is driven off the spellbook rather than off trained skills, which
is the obvious reading of "buff every trained and specialised skill": the
client cannot honestly make that mapping. The link between a spell and the
stat it modifies arrives from the SERVER in the enchantment message and is
absent from the client's own spell table. What the client does know is which
spells the character has learned -- and a character only learns buffs for the
skills they use, so the spellbook reaches the same set without inventing a
mapping the client has no grounds for.

Surface added, all BCL-only so Plugin.Abstractions keeps its zero project
references:

* ICharacterInfo, ISpellCatalog, IMagicCommands, grouped behind one
  IAutomationSurface so IPluginHost grows by one member rather than three.
* IEvents.Tick. Automation is sequences, not single calls -- a buff pass casts
  several spells and must wait between them. Without a host tick a plugin
  would need its own timer thread re-entering the host off its update thread.
* NoOpAutomationSurface for hosts with no live session, so a plugin keeps one
  code path and checks IsAvailable.

Markup gained <button> and <label>; it previously supported only <meter>, with
a comment promising the rest. Buttons bind onclick to an Action property and
FAIL THE PANEL LOAD if it does not resolve -- a silently dead button is worse
than a panel that refuses to load, because the user clicks and there is
nothing to diagnose. Labels bind through a Func so a status line tracks its
binding instead of freezing at build time.

Enchantment reads use EnchantmentsInEffectSnapshot rather than the raw active
set: retail leaves a weaker same-family enchantment in the registry while a
stronger one is in force, and a plugin asking "am I buffed?" means in force.

BuffPlan is a pure function of (known buffs, active enchantments) precisely so
it can be tested without a session; 9 tests cover tier supersede, the
family-0 no-stack bucket that must not be de-duplicated, expiry refresh, and
plan stability across the rebuilds the tick loop performs.

Solution builds clean; 14,421 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-20 16:22:28 +02:00
parent 690f21889e
commit 9d1117b923
27 changed files with 1546 additions and 10 deletions

View file

@ -11,6 +11,7 @@
<Project Path="src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj" />
<Project Path="src/AcDream.Platform/AcDream.Platform.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.MossTank/AcDream.Plugins.MossTank.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
@ -46,6 +47,7 @@
<Project Path="tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj" />
<Project Path="tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj" />
<Project Path="tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj" />
<Project Path="tests/AcDream.Plugins.MossTank.Tests/AcDream.Plugins.MossTank.Tests.csproj" />
<Project Path="tests/AcDream.Plugin.Tests.Fixtures.HostPlugin/AcDream.Plugin.Tests.Fixtures.HostPlugin.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />

View file

@ -146,4 +146,53 @@
Overwrite="true"
Lines="{ &quot;id&quot;: &quot;acdream.smoke&quot;, &quot;displayName&quot;: &quot;Smoke Plugin&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.Smoke.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target>
<!-- MossTank ships the same way as the smoke plugin, plus its panel markup:
MossTankPlugin resolves mosstank.xml relative to its own assembly, so the
two files must land in the same plugin directory. -->
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj">
<ReferenceOutputAssembly>false</ReferenceOutputAssembly>
<SkipGetTargetFrameworkProperties>true</SkipGetTargetFrameworkProperties>
</ProjectReference>
</ItemGroup>
<Target
Name="CopyMossTankPluginToBuildOutput"
AfterTargets="Build"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_MossTankSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankSourceDir>
<_MossTankSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankSourceDir)/$(RuntimeIdentifier)</_MossTankSourceDir>
<_MossTankDestDir>$(OutputPath)plugins/AcDream.Plugins.MossTank</_MossTankDestDir>
</PropertyGroup>
<MakeDir Directories="$(_MossTankDestDir)" />
<Copy
SourceFiles="$(_MossTankSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankSourceDir)/mosstank.xml"
DestinationFolder="$(_MossTankDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
File="$(_MossTankDestDir)/plugin.json"
Overwrite="true"
Lines="{ &quot;id&quot;: &quot;acdream.mosstank&quot;, &quot;displayName&quot;: &quot;MossTank&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.MossTank.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target>
<Target
Name="CopyMossTankPluginToPublishOutput"
AfterTargets="Publish"
Condition="'$(IsCrossTargetingBuild)' != 'true'">
<PropertyGroup>
<_MossTankPublishSourceDir>$(MSBuildProjectDirectory)/../AcDream.Plugins.MossTank/bin/$(Configuration)/$(TargetFramework)</_MossTankPublishSourceDir>
<_MossTankPublishSourceDir Condition="'$(RuntimeIdentifier)' != ''">$(_MossTankPublishSourceDir)/$(RuntimeIdentifier)</_MossTankPublishSourceDir>
<_MossTankPublishDestDir>$(PublishDir)plugins/AcDream.Plugins.MossTank</_MossTankPublishDestDir>
</PropertyGroup>
<MakeDir Directories="$(_MossTankPublishDestDir)" />
<Copy
SourceFiles="$(_MossTankPublishSourceDir)/AcDream.Plugins.MossTank.dll;$(_MossTankPublishSourceDir)/mosstank.xml"
DestinationFolder="$(_MossTankPublishDestDir)"
SkipUnchangedFiles="true" />
<WriteLinesToFile
File="$(_MossTankPublishDestDir)/plugin.json"
Overwrite="true"
Lines="{ &quot;id&quot;: &quot;acdream.mosstank&quot;, &quot;displayName&quot;: &quot;MossTank&quot;, &quot;version&quot;: &quot;0.1.0&quot;, &quot;entryDll&quot;: &quot;AcDream.Plugins.MossTank.dll&quot;, &quot;apiVersion&quot;: 1 }" />
</Target>
</Project>

View file

@ -0,0 +1,275 @@
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Plugins;
/// <summary>
/// Projects the canonical Runtime gameplay owners into the BCL-only plugin
/// automation surface.
/// </summary>
/// <remarks>
/// <para>
/// Owns nothing: the character state and cast state are borrowed from
/// <c>GameRuntime</c> and rebound per session, matching how every other
/// graphical projection treats Runtime owners. Between sessions the surface
/// reports <see cref="IsAvailable"/> false and every command refuses, rather
/// than throwing at a plugin that ticked one frame late.
/// </para>
/// <para>
/// The two snapshot lists are rebuilt on Spellbook change notifications rather
/// than per read, because a plugin ticking each frame would otherwise force a
/// full spellbook walk 60 times a second for data that changes rarely.
/// </para>
/// </remarks>
internal sealed class AppAutomationSurface
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands, IDisposable
{
private readonly object _gate = new();
private RuntimeCharacterState? _character;
private RuntimeSpellCastState? _cast;
private Spellbook? _spellbook;
private bool _disposed;
private IReadOnlyList<PluginSpellInfo> _knownSelfBuffs = Array.Empty<PluginSpellInfo>();
private IReadOnlyList<PluginActiveEnchantment> _enchantments =
Array.Empty<PluginActiveEnchantment>();
public bool IsAvailable
{
get
{
lock (_gate)
return !_disposed && _character is not null && _cast is not null;
}
}
public ICharacterInfo Character => this;
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
/// <summary>Bind the surface to a live session's owners.</summary>
public void Bind(RuntimeCharacterState character, RuntimeSpellCastState cast)
{
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(cast);
Spellbook spellbook = character.Spellbook;
lock (_gate)
{
if (_disposed)
return;
DetachLocked();
_character = character;
_cast = cast;
_spellbook = spellbook;
spellbook.SpellbookChanged += OnSpellbookChanged;
spellbook.EnchantmentsChanged += OnEnchantmentsChanged;
}
RebuildSpellbook();
RebuildEnchantments();
}
/// <summary>Release the session's owners; reads go inert until the next bind.</summary>
public void Unbind()
{
lock (_gate)
DetachLocked();
_knownSelfBuffs = Array.Empty<PluginSpellInfo>();
_enchantments = Array.Empty<PluginActiveEnchantment>();
}
private void DetachLocked()
{
if (_spellbook is not null)
{
_spellbook.SpellbookChanged -= OnSpellbookChanged;
_spellbook.EnchantmentsChanged -= OnEnchantmentsChanged;
}
_spellbook = null;
_character = null;
_cast = null;
}
private void OnSpellbookChanged() => RebuildSpellbook();
private void OnEnchantmentsChanged() => RebuildEnchantments();
private void RebuildSpellbook()
{
Spellbook? spellbook;
lock (_gate)
spellbook = _spellbook;
if (spellbook is null)
{
_knownSelfBuffs = Array.Empty<PluginSpellInfo>();
return;
}
var built = new List<PluginSpellInfo>();
foreach (uint spellId in spellbook.LearnedSpells)
{
if (!spellbook.TryGetMetadata(spellId, out SpellMetadata meta))
continue;
if (!meta.IsSelfTargeted || !meta.IsBeneficial || meta.IsDebuff)
continue;
built.Add(Project(meta));
}
// Stable order so a plugin's buff sequence does not reshuffle between
// passes: family first, then strongest tier within it.
built.Sort(static (a, b) =>
a.Family != b.Family
? a.Family.CompareTo(b.Family)
: b.Tier.CompareTo(a.Tier));
_knownSelfBuffs = built;
}
private void RebuildEnchantments()
{
Spellbook? spellbook;
lock (_gate)
spellbook = _spellbook;
if (spellbook is null)
{
_enchantments = Array.Empty<PluginActiveEnchantment>();
return;
}
// EnchantmentsInEffectSnapshot, not the raw active set: retail lets a
// weaker same-family enchantment sit in the registry while a stronger
// one is in force. A plugin asking "am I buffed?" means in force.
IReadOnlyList<ActiveEnchantmentRecord> active =
spellbook.EnchantmentsInEffectSnapshot;
var built = new List<PluginActiveEnchantment>(active.Count);
foreach (ActiveEnchantmentRecord record in active)
{
uint family = 0;
int tier = 0;
if (spellbook.TryGetMetadata(record.SpellId, out SpellMetadata meta))
{
family = meta.Family;
tier = meta.Generation;
}
built.Add(new PluginActiveEnchantment(
record.SpellId, family, tier, record.Duration));
}
_enchantments = built;
}
private static PluginSpellInfo Project(SpellMetadata meta) => new(
meta.SpellId,
meta.Name,
meta.Family,
meta.Generation,
meta.Difficulty,
meta.ManaCost,
meta.Duration,
meta.IsSelfTargeted,
meta.IsBeneficial);
// ── ICharacterInfo ────────────────────────────────────────────────────
public bool IsInWorld => IsAvailable;
public uint CurrentMana => Vital(out uint current, out _) ? current : 0u;
public uint MaxMana => Vital(out _, out uint maximum) ? maximum : 0u;
private bool Vital(out uint current, out uint maximum)
{
current = 0;
maximum = 0;
RuntimeCharacterState? character;
lock (_gate)
character = _character;
if (character is null)
return false;
if (!character.View.TryGetVital(
(int)LocalPlayerState.VitalKind.Mana, out var vital))
{
return false;
}
current = vital.Current;
maximum = vital.Maximum;
return true;
}
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => _enchantments;
// ── ISpellCatalog ─────────────────────────────────────────────────────
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => _knownSelfBuffs;
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
Spellbook? spellbook;
lock (_gate)
spellbook = _spellbook;
if (spellbook is not null
&& spellbook.TryGetMetadata(spellId, out SpellMetadata meta))
{
info = Project(meta);
return true;
}
info = default;
return false;
}
// ── IMagicCommands ────────────────────────────────────────────────────
public bool IsCasting
{
get
{
RuntimeSpellCastState? cast;
lock (_gate)
cast = _cast;
return cast?.LastRequestedSpellId is not null;
}
}
public PluginCastGate EvaluateGate(uint spellId)
{
RuntimeSpellCastState? cast;
Spellbook? spellbook;
lock (_gate)
{
cast = _cast;
spellbook = _spellbook;
}
if (cast is null || spellbook is null)
return PluginCastGate.Unavailable;
if (!spellbook.Knows(spellId))
return PluginCastGate.NotKnown;
return cast.EvaluateCastGate(spellId) switch
{
SpellCastGate.Unknown => PluginCastGate.NotKnown,
SpellCastGate.NoTargetNeeded => PluginCastGate.Ready,
SpellCastGate.TargetCompatible => PluginCastGate.Ready,
_ => PluginCastGate.Refused,
};
}
public bool Cast(uint spellId)
{
RuntimeSpellCastState? cast;
lock (_gate)
cast = _cast;
return cast is not null && cast.Cast(spellId) == CastRequestResult.Sent;
}
public void Dispose()
{
lock (_gate)
{
if (_disposed)
return;
_disposed = true;
DetachLocked();
}
_knownSelfBuffs = Array.Empty<PluginSpellInfo>();
_enchantments = Array.Empty<PluginActiveEnchantment>();
}
}

View file

@ -9,13 +9,15 @@ public sealed class AppPluginHost : IPluginHost
IGameState state,
IEvents events,
ISelectionService selection,
IUiRegistry ui)
IUiRegistry ui,
IAutomationSurface automation)
{
Log = log;
State = state;
Events = events;
Selection = selection;
Ui = ui;
Automation = automation;
}
public bool HasUi => true;
@ -24,4 +26,5 @@ public sealed class AppPluginHost : IPluginHost
public IEvents Events { get; }
public ISelectionService Selection { get; }
public IUiRegistry Ui { get; }
public IAutomationSurface Automation { get; }
}

View file

@ -149,18 +149,23 @@ if (runtimeOptions.DevTools)
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry();
// Constructed here and handed to both sides: GameWindow binds it to the live
// session's Runtime owners, the plugin host exposes it to plugins.
using var automation = new AcDream.App.Plugins.AppAutomationSurface();
using var window = new GameWindow(
runtimeOptions,
worldGameState,
worldEvents,
uiRegistry,
graphicalPlatform);
graphicalPlatform,
automation);
var host = new AppPluginHost(
new SerilogAdapter(Log.Logger),
worldGameState,
worldEvents,
window.Selection,
uiRegistry);
uiRegistry,
automation);
GraphicalPluginSession pluginSession = GraphicalPluginSession.Create(
applicationPaths,
runtimeOptions.Plugins,

View file

@ -363,6 +363,7 @@ public sealed class GameWindow :
// J4/J5.1: Runtime owns communication, selection, combat, and target-mode
// state. App, UI, plugins, and live-session routing borrow exact children.
private readonly AcDream.App.Plugins.AppAutomationSurface? _automation;
private readonly GameRuntime _runtime;
private readonly IDisposable _runtimeHostLease;
private RuntimeCommunicationState _runtimeCommunication =>
@ -634,9 +635,11 @@ public sealed class GameWindow :
WorldGameState worldGameState,
WorldEvents worldEvents,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry,
GraphicalHostPlatformServices platformServices)
GraphicalHostPlatformServices platformServices,
AcDream.App.Plugins.AppAutomationSurface? automation = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_automation = automation;
_statusWriter = new SessionStatusWriter(options.StatusFilePath);
_platformServices = platformServices
?? throw new ArgumentNullException(nameof(platformServices));
@ -653,6 +656,11 @@ public sealed class GameWindow :
options.DumpSky ? Console.WriteLine : null));
_runtimeHostLease = _runtime.AcquireHostLease(
"graphical GameWindow");
// Bound once, not per session: since Slice J the gameplay owners are
// stable for the GameRuntime's lifetime and it is their *contents* that
// reset across generations. Re-binding per session would be re-binding
// the same two references.
_automation?.Bind(_runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
_runtime.PlayerIdentity);
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
@ -1626,6 +1634,11 @@ public sealed class GameWindow :
using var _updStage = _frameProfiler.BeginStage(
AcDream.App.Diagnostics.FrameStage.Update);
_frameGraphs.Tick(new AcDream.App.Update.UpdateFrameInput(dt));
// After the frame graph, so a plugin observes state the host has
// already advanced this frame rather than a half-updated world.
// WorldEvents.FireTick swallows per-subscriber faults, so a throwing
// plugin cannot break the render loop.
_worldEvents.FireTick(dt);
_renderLoopArmed = false;
}

View file

@ -75,12 +75,100 @@ public static class MarkupDocument
FrontRight = Hex((string?)el.Attribute("frontright")),
});
break;
// future element kinds (label, button, image) added here
case "label":
// Text may be a literal or a {Binding}. Bound labels re-read
// their property every frame through the Func, so a plugin
// updates its status line by assigning a property rather
// than by touching UI objects from its own thread.
var label = new UiLabel
{
Left = F(el, "x"),
Top = F(el, "y"),
TextSource = BindString((string?)el.Attribute("text"), binding),
};
if (el.Attribute("color") is not null)
label.TextColor = Color((string?)el.Attribute("color"));
panel.AddChild(label);
break;
case "button":
// onclick binds to an Action property on the binding
// object. Resolved once at build time: a button whose
// handler silently failed to bind is a bug worth failing
// loudly for, and MarkupDocument.Build is already inside
// the host's try/catch that reports panel load failures.
string? clickName = (string?)el.Attribute("onclick");
Action? onClick = BindAction(clickName, binding);
if (clickName is not null && onClick is null)
{
throw new FormatException(
$"<button onclick=\"{clickName}\"> did not resolve to an "
+ $"Action property on {binding.GetType().Name}");
}
// UiSimpleButton, not the dat-sprite UiButton: a plugin
// panel has no LayoutDesc behind it and no StateDesc
// sprites to name, so the plain rect-and-text button is the
// one that can actually render from markup alone.
var button = new UiSimpleButton
{
Left = F(el, "x"),
Top = F(el, "y"),
Width = F(el, "w"),
Height = F(el, "h"),
Text = (string?)el.Attribute("text") ?? string.Empty,
};
if (el.Attribute("color") is not null)
button.TextColor = Color((string?)el.Attribute("color"));
if (onClick is not null)
button.Click += onClick;
panel.AddChild(button);
break;
}
}
return panel;
}
/// <summary>
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
/// literal text unchanged. The indirection matters: binding to a
/// <see cref="Func{T}"/> rather than copying the value once is what makes a
/// plugin's status text update as its state changes.
/// </summary>
private static Func<string?> BindString(string? attribute, object binding)
{
if (attribute is null)
return static () => null;
if (!IsBinding(attribute))
return () => attribute;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null)
return () => attribute;
return () => property.GetValue(binding)?.ToString();
}
/// <summary>Resolves <c>{PropName}</c> to an <see cref="Action"/> property.</summary>
private static Action? BindAction(string? attribute, object binding)
{
if (attribute is null || !IsBinding(attribute))
return null;
string name = attribute[1..^1];
PropertyInfo? property = binding.GetType().GetProperty(name);
if (property is null || !typeof(Action).IsAssignableFrom(property.PropertyType))
return null;
// Read through on each click rather than capturing the delegate now, so
// a binding object may swap its handler (or null it out while busy)
// without rebuilding the panel.
return () => (property.GetValue(binding) as Action)?.Invoke();
}
private static bool IsBinding(string value) =>
value.Length > 2 && value[0] == '{' && value[^1] == '}';
private static float F(XElement e, string attr)
=> float.TryParse((string?)e.Attribute(attr), NumberStyles.Float,
CultureInfo.InvariantCulture, out var v) ? v : 0f;

View file

@ -69,10 +69,19 @@ public class UiLabel : UiElement
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>
/// Optional live text reader, evaluated each draw and preferred over
/// <see cref="Text"/> when set. Markup <c>{Binding}</c> labels use this so
/// the displayed value tracks the binding object instead of freezing at
/// whatever it held when the panel was built — which is what a plugin
/// status line needs.
/// </summary>
public Func<string?>? TextSource { get; set; }
public UiLabel() { ClickThrough = true; }
protected override void OnDraw(UiRenderContext ctx)
=> ctx.DrawString(Text, 0, 0, TextColor);
=> ctx.DrawString(TextSource?.Invoke() ?? Text, 0, 0, TextColor);
}
/// <summary>

View file

@ -30,6 +30,21 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public ISelectionService Selection => _selection;
public IUiRegistry Ui => _ui;
/// <summary>
/// Delegated rather than scoped, unlike <see cref="Events"/>,
/// <see cref="Selection"/> and <see cref="Ui"/>.
/// </summary>
/// <remarks>
/// Automation has no registrations to roll back — it is reads plus a cast
/// call. What actually stops a disabled plugin acting is that its
/// <c>Events.Tick</c> subscription is revoked with the scoped event source,
/// so its buff loop stops being driven. A plugin that cached the surface
/// could still issue a cast from some other callback; that is acceptable
/// while plugins are first-party and trusted, and the fix if that changes
/// is a revocable wrapper here, not a change at the call site.
/// </remarks>
public IAutomationSurface Automation => _inner.Automation;
public void Dispose()
{
if (_disposed)
@ -142,8 +157,60 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
{
private readonly object _gate = new();
private readonly List<Action<WorldEntitySnapshot>> _registrations = [];
private readonly List<Action<double>> _tickRegistrations = [];
private bool _disposed;
/// <summary>
/// Same transactional revoke contract as <see cref="EntitySpawned"/>.
/// Revoking this on teardown is what actually stops a disabled plugin
/// acting on the world: no tick, no automation loop.
/// </summary>
public event Action<double> Tick
{
add
{
ArgumentNullException.ThrowIfNull(value);
try
{
inner.Tick += value;
}
catch
{
try { inner.Tick -= value; }
catch { }
throw;
}
lock (_gate)
{
if (!_disposed)
{
_tickRegistrations.Add(value);
return;
}
}
try { inner.Tick -= value; }
catch { }
throw new ObjectDisposedException(nameof(ScopedEvents));
}
remove
{
if (value is null)
return;
inner.Tick -= value;
lock (_gate)
{
for (int index = _tickRegistrations.Count - 1; index >= 0; index--)
{
if (_tickRegistrations[index] != value)
continue;
_tickRegistrations.RemoveAt(index);
break;
}
}
}
}
public event Action<WorldEntitySnapshot> EntitySpawned
{
add
@ -190,6 +257,7 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
public void Dispose()
{
Action<WorldEntitySnapshot>[] registrations;
Action<double>[] tickRegistrations;
lock (_gate)
{
if (_disposed)
@ -197,6 +265,8 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
_disposed = true;
registrations = _registrations.ToArray();
_registrations.Clear();
tickRegistrations = _tickRegistrations.ToArray();
_tickRegistrations.Clear();
}
for (int index = registrations.Length - 1; index >= 0; index--)
@ -204,6 +274,12 @@ internal sealed class ScopedPluginHost : IPluginHost, IDisposable
try { inner.EntitySpawned -= registrations[index]; }
catch { }
}
for (int index = tickRegistrations.Length - 1; index >= 0; index--)
{
try { inner.Tick -= tickRegistrations[index]; }
catch { }
}
}
private void RemoveLast(Action<WorldEntitySnapshot> handler)

View file

@ -12,6 +12,7 @@ public sealed class WorldEvents : IEvents
private readonly Dictionary<uint, WorldEntitySnapshot> _current = new();
private readonly List<Subscription> _subscriptions = new();
private Subscription[] _liveSnapshot = Array.Empty<Subscription>();
private Action<double>? _tick;
private sealed class Subscription(Action<WorldEntitySnapshot> handler)
{
@ -77,6 +78,46 @@ public sealed class WorldEvents : IEvents
}
}
/// <summary>
/// Raised by the host once per update. Unlike <see cref="EntitySpawned"/>
/// there is no replay: a tick is a moment, not a fact about the world, and
/// replaying one to a late subscriber would be meaningless.
/// </summary>
public event Action<double> Tick
{
add
{
ArgumentNullException.ThrowIfNull(value);
lock (_lock)
_tick += value;
}
remove
{
if (value is null)
return;
lock (_lock)
_tick -= value;
}
}
/// <summary>Called by the host on its update thread.</summary>
public void FireTick(double elapsedSeconds)
{
Action<double>? handlers;
lock (_lock)
handlers = _tick;
if (handlers is null)
return;
// Invoked per subscriber rather than as one multicast call so a single
// throwing plugin cannot suppress every later subscriber's tick.
foreach (Delegate handler in handlers.GetInvocationList())
{
try { ((Action<double>)handler)(elapsedSeconds); }
catch { /* plugin errors don't propagate out of event dispatch */ }
}
}
public event Action<WorldEntitySnapshot> EntitySpawned
{
add

View file

@ -50,8 +50,34 @@ internal sealed class HeadlessPluginHost
public IGameState State => this;
public IEvents Events => this;
public ISelectionService Selection => _runtime.ActionOwner.Selection;
/// <summary>
/// No live-session projection is bound on this host yet, so automation is
/// inert rather than absent -- a plugin keeps one code path and checks
/// IsAvailable.
/// </summary>
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
public IUiRegistry Ui => NoOpUiRegistry.Instance;
/// <summary>
/// Accepted but never raised on this host: the headless bot loop drives its
/// policies through Runtime's own scheduler, not through a plugin tick, so
/// there is no update to forward. A plugin written against the graphical
/// host therefore loads and runs here, it simply never ticks. Wiring it is a
/// Slice-K scheduler change rather than a plugin-API one.
/// </summary>
/// <remarks>
/// Written with explicit accessors rather than as a field-like event on
/// purpose: a field-like event that is never raised is a compiler warning,
/// and this project builds warnings as errors. Discarding here states the
/// intent instead of suppressing the diagnostic.
/// </remarks>
public event Action<double> Tick
{
add { _ = value; }
remove { _ = value; }
}
/// <summary>Test-only barrier invoked after the first replay item is
/// captured while Runtime's exact active-membership read lease is still
/// held.</summary>

View file

@ -0,0 +1,155 @@
namespace AcDream.Plugin.Abstractions;
/// <summary>
/// One spell, as much of it as a plugin needs to make its own decisions.
/// </summary>
/// <remarks>
/// Deliberately <b>data, not policy</b>. The host publishes what the spell
/// table says; the plugin decides what to cast and when. That line is the whole
/// architectural point of this surface — a Virindi-Tank-class engine belongs in
/// plugin-land, built on host primitives, exactly as VTank itself was built on
/// Decal's. Bake "best buff for skill X" into the host and the engine starts
/// migrating inward, one convenience at a time.
/// </remarks>
/// <param name="Family">
/// Retail's stacking bucket. Only one enchantment per family is in force, so
/// this is how a plugin answers "am I already buffed with this?". Family 0
/// means "does not stack" and must not be de-duplicated.
/// </param>
/// <param name="Tier">
/// Retail's spell <c>Generation</c> — the roman-numeral level. Higher is
/// stronger within a family.
/// </param>
public readonly record struct PluginSpellInfo(
uint SpellId,
string Name,
uint Family,
int Tier,
int Difficulty,
int ManaCost,
float DurationSeconds,
bool IsSelfTargeted,
bool IsBeneficial);
/// <summary>One enchantment currently in force on the local player.</summary>
/// <param name="Family">
/// Resolved from the spell table by the host, so a plugin can compare it
/// against a candidate's family without carrying its own spell data.
/// </param>
public readonly record struct PluginActiveEnchantment(
uint SpellId,
uint Family,
int Tier,
double SecondsRemaining);
/// <summary>Why a cast would or would not be accepted right now.</summary>
public enum PluginCastGate
{
/// <summary>No live session, or the surface is not bound yet.</summary>
Unavailable = 0,
Ready,
NotKnown,
NotEnoughMana,
MissingComponents,
/// <summary>A cast is already in flight.</summary>
Busy,
/// <summary>The host rejected it for a reason not modelled here.</summary>
Refused,
}
/// <summary>Local-player reads a plugin needs to decide what to cast.</summary>
public interface ICharacterInfo
{
bool IsInWorld { get; }
uint CurrentMana { get; }
uint MaxMana { get; }
/// <summary>
/// Enchantments in force on the local player. Snapshot semantics: the list
/// is rebuilt by the host, never mutated in place under a reader.
/// </summary>
IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; }
}
/// <summary>Spell-table data, filtered to what the local character knows.</summary>
public interface ISpellCatalog
{
/// <summary>
/// Every spell in the character's spellbook that targets self and is
/// beneficial — i.e. the complete set of self-buffs this character can
/// actually cast, which for a played character is precisely the buffs for
/// the skills they use.
/// </summary>
IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; }
bool TryGet(uint spellId, out PluginSpellInfo info);
}
/// <summary>Casting, with a preflight so a plugin need not guess.</summary>
public interface IMagicCommands
{
bool IsCasting { get; }
PluginCastGate EvaluateGate(uint spellId);
/// <summary>
/// Request a cast. Returns whether the request was accepted for dispatch —
/// not whether the spell ultimately lands, which the server decides.
/// </summary>
bool Cast(uint spellId);
}
/// <summary>
/// The automation surface: reads, spell data, and commands, grouped so
/// <see cref="IPluginHost"/> grows by one member rather than three.
/// </summary>
public interface IAutomationSurface
{
/// <summary>
/// <see langword="false"/> on hosts that never bind a live session, and
/// while a graphical host is between sessions.
/// </summary>
bool IsAvailable { get; }
ICharacterInfo Character { get; }
ISpellCatalog Spells { get; }
IMagicCommands Magic { get; }
}
/// <summary>
/// BCL-only inert surface for hosts with no live session. Every read is empty
/// and every command refuses, so a plugin can keep one code path.
/// </summary>
public sealed class NoOpAutomationSurface
: IAutomationSurface, ICharacterInfo, ISpellCatalog, IMagicCommands
{
public static NoOpAutomationSurface Instance { get; } = new();
private NoOpAutomationSurface()
{
}
public bool IsAvailable => false;
public ICharacterInfo Character => this;
public ISpellCatalog Spells => this;
public IMagicCommands Magic => this;
public bool IsInWorld => false;
public uint CurrentMana => 0;
public uint MaxMana => 0;
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; } =
Array.Empty<PluginActiveEnchantment>();
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; } =
Array.Empty<PluginSpellInfo>();
public bool TryGet(uint spellId, out PluginSpellInfo info)
{
info = default;
return false;
}
public bool IsCasting => false;
public PluginCastGate EvaluateGate(uint spellId) => PluginCastGate.Unavailable;
public bool Cast(uint spellId) => false;
}

View file

@ -4,4 +4,17 @@ namespace AcDream.Plugin.Abstractions;
public interface IEvents
{
event Action<WorldEntitySnapshot> EntitySpawned;
/// <summary>
/// Raised once per host update with the elapsed seconds since the previous
/// tick, on the host's own update thread.
/// </summary>
/// <remarks>
/// Automation needs this because useful actions are sequences, not single
/// calls: a buff pass casts several spells, each taking seconds, and has to
/// wait for one to finish before starting the next. Without a host tick a
/// plugin would have to run its own timer thread and re-enter the host off
/// its update thread, which is exactly the race this avoids.
/// </remarks>
event Action<double> Tick;
}

View file

@ -20,4 +20,13 @@ public interface IPluginHost
IEvents Events { get; }
ISelectionService Selection { get; }
IUiRegistry Ui { get; }
/// <summary>
/// Character reads, spell data and casting. Hosts with no live session
/// expose <see cref="NoOpAutomationSurface.Instance"/>, so a plugin may
/// hold one code path and check
/// <see cref="IAutomationSurface.IsAvailable"/> rather than branching on
/// host kind.
/// </summary>
IAutomationSurface Automation { get; }
}

View file

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Plugin DLLs are copied to plugins/<id>/ at build of AcDream.App.
They must NOT bring AcDream.Plugin.Abstractions.dll with them;
the host already owns it. -->
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Plugins.MossTank.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj">
<Private>false</Private>
<ExcludeAssets>runtime</ExcludeAssets>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Update="mosstank.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,109 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Decides which self-buffs are missing and in what order to cast them.
/// </summary>
/// <remarks>
/// <para>
/// Pure function of (known self-buffs, active enchantments). No host calls, no
/// state, no clock — so the buff policy can be reasoned about and tested
/// without a live session, which is the whole reason it lives here rather than
/// in the host.
/// </para>
/// <para>
/// <b>Why the spellbook is the source of truth.</b> The obvious reading of
/// "buff every trained and specialised skill" is to enumerate skills and map
/// each to its buff line. The client cannot do that honestly: the link between
/// a spell and the stat it modifies arrives from the <em>server</em>, in the
/// enchantment message, and is absent from the client's own spell table. What
/// the client does know is which spells the character has learned — and a
/// character only learns the buffs for the skills they actually use. Driving
/// from the spellbook reaches the same set without inventing a mapping the
/// client has no grounds for.
/// </para>
/// </remarks>
internal static class BuffPlan
{
/// <summary>
/// The strongest known buff per family that is not already in force at an
/// equal or higher tier, ordered so the plan is stable between passes.
/// </summary>
public static List<PluginSpellInfo> Build(
IReadOnlyList<PluginSpellInfo> knownSelfBuffs,
IReadOnlyList<PluginActiveEnchantment> active,
double refreshWhenUnderSeconds)
{
// Best known candidate per family. Family 0 is retail's "does not
// stack" bucket: those spells share no family identity, so collapsing
// them would drop all but one unrelated buff.
var bestByFamily = new Dictionary<uint, PluginSpellInfo>();
var unstackable = new List<PluginSpellInfo>();
foreach (PluginSpellInfo spell in knownSelfBuffs)
{
if (spell.Family == 0)
{
unstackable.Add(spell);
continue;
}
if (!bestByFamily.TryGetValue(spell.Family, out PluginSpellInfo held)
|| spell.Tier > held.Tier)
{
bestByFamily[spell.Family] = spell;
}
}
// Strongest in-force tier per family, and how long it has left.
var activeByFamily = new Dictionary<uint, (int Tier, double Seconds)>();
var activeSpellSeconds = new Dictionary<uint, double>();
foreach (PluginActiveEnchantment enchantment in active)
{
activeSpellSeconds[enchantment.SpellId] = enchantment.SecondsRemaining;
if (enchantment.Family == 0)
continue;
if (!activeByFamily.TryGetValue(enchantment.Family, out var held)
|| enchantment.Tier > held.Tier)
{
activeByFamily[enchantment.Family] =
(enchantment.Tier, enchantment.SecondsRemaining);
}
}
var plan = new List<PluginSpellInfo>();
foreach (PluginSpellInfo candidate in bestByFamily.Values)
{
if (!activeByFamily.TryGetValue(candidate.Family, out var inForce))
{
plan.Add(candidate);
continue;
}
// A weaker enchantment in force is still worth replacing: recasting
// at a higher tier supersedes it.
if (candidate.Tier > inForce.Tier
|| inForce.Seconds < refreshWhenUnderSeconds)
{
plan.Add(candidate);
}
}
foreach (PluginSpellInfo candidate in unstackable)
{
if (!activeSpellSeconds.TryGetValue(candidate.SpellId, out double seconds)
|| seconds < refreshWhenUnderSeconds)
{
plan.Add(candidate);
}
}
// Cheapest first: if mana runs out mid-pass, more buffs landed than if
// the expensive ones had gone first.
plan.Sort(static (a, b) =>
a.ManaCost != b.ManaCost
? a.ManaCost.CompareTo(b.ManaCost)
: a.SpellId.CompareTo(b.SpellId));
return plan;
}
}

View file

@ -0,0 +1,184 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// The panel's binding object and the buff loop's state machine.
/// </summary>
/// <remarks>
/// <para>
/// The markup binds <c>{Buff}</c> to <see cref="Buff"/> and its labels to the
/// status properties. Everything here runs on the host's update thread — the
/// button click and the tick both arrive there — so no locking is needed, and
/// none is used, deliberately: adding a lock would imply a second thread that
/// does not exist.
/// </para>
/// <para>
/// <b>Pacing.</b> The loop casts one spell, then waits. There is no
/// cast-completed event in the plugin API yet, so completion is inferred the
/// honest way: re-evaluate the plan each pass and let landed buffs drop out of
/// it. A fizzle simply leaves its buff missing and it gets retried on the next
/// pass. That is self-correcting without pretending to know an outcome the
/// host has not reported.
/// </para>
/// </remarks>
internal sealed class MossTankPanel
{
/// <summary>Roughly a retail cast plus windup, so casts do not stack up.</summary>
private const double CastIntervalSeconds = 3.0;
/// <summary>Refresh a buff already in force but nearly expired.</summary>
private const double RefreshWhenUnderSeconds = 60.0;
/// <summary>Give up on a pass that stops making progress.</summary>
private const double StallTimeoutSeconds = 20.0;
private readonly IPluginHost _host;
private List<PluginSpellInfo> _plan = new();
private int _planIndex;
private bool _running;
private double _sinceLastCast;
private double _sinceProgress;
private int _castThisPass;
private string _status = "Idle.";
public MossTankPanel(IPluginHost host) => _host = host;
/// <summary>Bound to the panel's Buff button.</summary>
public Action Buff => StartOrStop;
public string Title => "MossTank";
public string Status => _status;
public string ButtonText => _running ? "Stop" : "Buff";
public string Detail
{
get
{
IAutomationSurface automation = _host.Automation;
if (!automation.IsAvailable)
return "Not in world.";
int known = automation.Spells.KnownSelfBuffs.Count;
int active = automation.Character.ActiveEnchantments.Count;
return $"{known} self-buffs known / {active} active"
+ $" · mana {automation.Character.CurrentMana}"
+ $"/{automation.Character.MaxMana}";
}
}
private void StartOrStop()
{
if (_running)
{
Stop("Stopped.");
return;
}
IAutomationSurface automation = _host.Automation;
if (!automation.IsAvailable)
{
_status = "Not in world.";
return;
}
_plan = BuffPlan.Build(
automation.Spells.KnownSelfBuffs,
automation.Character.ActiveEnchantments,
RefreshWhenUnderSeconds);
_planIndex = 0;
_castThisPass = 0;
_sinceLastCast = CastIntervalSeconds; // cast the first one immediately
_sinceProgress = 0;
if (_plan.Count == 0)
{
_status = "Already fully buffed.";
return;
}
_running = true;
_status = $"Buffing 0/{_plan.Count}…";
_host.Log.Info($"MossTank: buff pass started, {_plan.Count} spell(s) to cast");
}
private void Stop(string status)
{
_running = false;
_plan = new List<PluginSpellInfo>();
_planIndex = 0;
_status = status;
}
/// <summary>Driven by <see cref="IEvents.Tick"/> on the host update thread.</summary>
public void OnTick(double elapsedSeconds)
{
if (!_running)
return;
IAutomationSurface automation = _host.Automation;
if (!automation.IsAvailable)
{
Stop("Lost the session.");
return;
}
_sinceLastCast += elapsedSeconds;
_sinceProgress += elapsedSeconds;
if (_sinceProgress > StallTimeoutSeconds)
{
Stop($"Stalled after {_castThisPass} cast(s).");
_host.Log.Warn("MossTank: buff pass stalled; stopping");
return;
}
if (_sinceLastCast < CastIntervalSeconds || automation.Magic.IsCasting)
return;
// Re-derive against current enchantments so anything that landed since
// the pass began drops out rather than being cast twice.
_plan = BuffPlan.Build(
automation.Spells.KnownSelfBuffs,
automation.Character.ActiveEnchantments,
RefreshWhenUnderSeconds);
if (_plan.Count == 0)
{
Stop($"Done — {_castThisPass} cast(s).");
_host.Log.Info($"MossTank: buff pass complete ({_castThisPass} cast)");
return;
}
if (_planIndex >= _plan.Count)
_planIndex = 0;
PluginSpellInfo next = _plan[_planIndex];
PluginCastGate gate = automation.Magic.EvaluateGate(next.SpellId);
if (gate != PluginCastGate.Ready)
{
// Skip it rather than blocking the pass; the next tick tries the
// one after. A permanently ungateable spell falls out when the
// stall timeout fires.
_planIndex++;
_status = $"Skipped {next.Name} ({gate}).";
return;
}
if (automation.Magic.Cast(next.SpellId))
{
_castThisPass++;
_sinceLastCast = 0;
_sinceProgress = 0;
_planIndex = 0;
_status = $"Casting {next.Name} ({_castThisPass} cast)…";
_host.Log.Info($"MossTank: casting {next.Name} (0x{next.SpellId:X4})");
}
else
{
_planIndex++;
_status = $"Refused {next.Name}.";
}
}
}

View file

@ -0,0 +1,59 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// MossTank — a self-buffing plugin, and the first consumer of acdream's
/// plugin automation surface.
/// </summary>
/// <remarks>
/// Named for the mosswart, and for the Virindi Tank lineage this milestone is
/// modelled on. The buff policy lives here rather than in the host on purpose:
/// the host publishes spell data and a cast primitive, the plugin decides what
/// to cast. See <c>docs/research/2026-07-29-vtank-plugin-automation-requirements.md</c>.
/// </remarks>
public sealed class MossTankPlugin : IAcDreamPlugin
{
private IPluginHost? _host;
private MossTankPanel? _panel;
private Action<double>? _tick;
public void Initialize(IPluginHost host)
{
_host = host;
_panel = new MossTankPanel(host);
host.Log.Info("MossTank initialized");
}
public void Enable()
{
if (_host is null || _panel is null)
return;
// Markup ships beside the plugin assembly, so it is found relative to
// this DLL rather than the host's working directory -- plugins are
// loaded from their own directory and the two are not the same.
string markup = Path.Combine(
Path.GetDirectoryName(typeof(MossTankPlugin).Assembly.Location) ?? ".",
"mosstank.xml");
_host.Ui.AddMarkupPanel(markup, _panel);
_tick = _panel.OnTick;
_host.Events.Tick += _tick;
_host.Log.Info(
_host.Automation.IsAvailable
? "MossTank enabled"
: "MossTank enabled (no live session yet; the Buff button will "
+ "report 'Not in world' until one is up)");
}
public void Disable()
{
if (_host is not null && _tick is not null)
_host.Events.Tick -= _tick;
_tick = null;
_host?.Log.Info("MossTank disabled");
}
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MossTank's panel. Bindings resolve by property name against MossTankPanel:
{Buff} is an Action bound to the button, the rest are read every frame. -->
<panel x="40" y="120" w="300" h="118" title="MossTank">
<label x="12" y="28" text="{Detail}" color="#FFB9C7A0" />
<label x="12" y="48" text="{Status}" color="#FFE8E4C8" />
<button x="12" y="72" w="104" h="28" text="Buff" onclick="{Buff}" />
</panel>

View file

@ -0,0 +1,10 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"acdream.plugin.abstractions": {
"type": "Project"
}
}
}
}

View file

@ -30,7 +30,8 @@ public sealed class GraphicalPluginSessionTests
var events = new WorldEvents();
var selection = new SelectionState();
var ui = new BufferedUiRegistry();
var host = new AppPluginHost(logger, state, events, selection, ui);
var host = new AppPluginHost(logger, state, events, selection, ui,
NoOpAutomationSurface.Instance);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
@ -85,7 +86,8 @@ public sealed class GraphicalPluginSessionTests
new WorldGameState(),
new WorldEvents(),
new SelectionState(),
ui);
ui,
NoOpAutomationSurface.Instance);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
@ -123,7 +125,8 @@ public sealed class GraphicalPluginSessionTests
new WorldGameState(),
events,
selection,
ui);
ui,
NoOpAutomationSurface.Instance);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,
@ -176,7 +179,8 @@ public sealed class GraphicalPluginSessionTests
new WorldGameState(),
events,
selection,
ui);
ui,
NoOpAutomationSurface.Instance);
using GraphicalPluginSession plugins = GraphicalPluginSession.Create(
paths,

View file

@ -75,4 +75,77 @@ public class MarkupDocumentTests
Assert.Equal(0x06001133u, meter.FrontRight);
Assert.NotNull(meter.SpriteResolve);
}
private sealed class ButtonBinding
{
public int Clicks { get; private set; }
public string Status { get; set; } = "idle";
public Action Go => () => Clicks++;
public Action? Missing => null;
}
[Fact]
public void Build_ButtonInvokesTheBoundActionOnClick()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
" <button x=\"4\" y=\"8\" w=\"60\" h=\"20\" text=\"Buff\" onclick=\"{Go}\"/>" +
"</panel>";
var binding = new ButtonBinding();
var panel = MarkupDocument.Build(xml, binding, _ => ((uint)1, 32, 32));
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
Assert.Equal("Buff", button.Text);
Assert.Equal(60f, button.Width);
button.OnEvent(new UiEvent { Type = UiEventType.Click });
button.OnEvent(new UiEvent { Type = UiEventType.Click });
Assert.Equal(2, binding.Clicks);
}
[Fact]
public void Build_ButtonWithUnresolvableHandlerFailsLoudly()
{
// A silently dead button is worse than a panel that refuses to load:
// the user clicks and nothing happens, with nothing to diagnose.
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
" <button x=\"0\" y=\"0\" w=\"10\" h=\"10\" text=\"X\" onclick=\"{NoSuchProperty}\"/>" +
"</panel>";
Assert.Throws<FormatException>(
() => MarkupDocument.Build(xml, new ButtonBinding(), _ => ((uint)1, 32, 32)));
}
[Fact]
public void Build_BoundLabelTracksTheBindingRatherThanFreezing()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
" <label x=\"2\" y=\"4\" text=\"{Status}\"/>" +
"</panel>";
var binding = new ButtonBinding();
var panel = MarkupDocument.Build(xml, binding, _ => ((uint)1, 32, 32));
var label = Assert.IsType<UiLabel>(panel.Children[0]);
Assert.Equal("idle", label.TextSource!());
binding.Status = "casting";
Assert.Equal("casting", label.TextSource!());
}
[Fact]
public void Build_LiteralLabelTextIsUsedVerbatim()
{
const string xml =
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
" <label x=\"0\" y=\"0\" text=\"MossTank\"/>" +
"</panel>";
var panel = MarkupDocument.Build(xml, new ButtonBinding(), _ => ((uint)1, 32, 32));
var label = Assert.IsType<UiLabel>(panel.Children[0]);
Assert.Equal("MossTank", label.TextSource!());
}
}

View file

@ -34,6 +34,7 @@ public class PluginLoaderTests
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new SelectionState();
public IUiRegistry Ui { get; } = new StubUiRegistry();
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class StubUiRegistry : IUiRegistry
@ -60,6 +61,12 @@ public class PluginLoaderTests
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
[Fact]

View file

@ -157,6 +157,7 @@ public sealed class PluginSessionTests
public IEvents Events { get; } = new StubEvents();
public ISelectionService Selection { get; } = new SelectionState();
public IUiRegistry Ui => NoOpUiRegistry.Instance;
public IAutomationSurface Automation => NoOpAutomationSurface.Instance;
}
private sealed class StubLogger : IPluginLogger
@ -178,6 +179,12 @@ public sealed class PluginSessionTests
add { }
remove { }
}
public event Action<double> Tick
{
add { }
remove { }
}
}
private sealed class TemporaryDirectory : IDisposable

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Plugins.MossTank\AcDream.Plugins.MossTank.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,150 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank;
namespace AcDream.Plugins.MossTank.Tests;
/// <summary>
/// The buff policy is the part of MossTank that decides what gets cast, so it
/// is the part worth pinning. It is a pure function of (known buffs, active
/// enchantments) precisely so these tests need no host and no session.
/// </summary>
public class BuffPlanTests
{
private const double Refresh = 60.0;
private static PluginSpellInfo Spell(
uint id, uint family, int tier, int mana = 10) =>
new(id, $"spell-{id}", family, tier, Difficulty: 100, ManaCost: mana,
DurationSeconds: 1800f, IsSelfTargeted: true, IsBeneficial: true);
private static PluginActiveEnchantment Active(
uint id, uint family, int tier, double seconds) =>
new(id, family, tier, seconds);
[Fact]
public void WithNothingActive_CastsTheStrongestTierPerFamily()
{
var known = new[]
{
Spell(1, family: 10, tier: 1),
Spell(2, family: 10, tier: 7),
Spell(3, family: 20, tier: 4),
};
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
Assert.Equal(2, plan.Count);
Assert.Contains(plan, s => s.SpellId == 2); // tier 7 beat tier 1
Assert.Contains(plan, s => s.SpellId == 3);
Assert.DoesNotContain(plan, s => s.SpellId == 1);
}
[Fact]
public void SkipsFamiliesAlreadyInForceAtTheSameTier()
{
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(2, family: 10, tier: 7, seconds: 900) };
Assert.Empty(BuffPlan.Build(known, active, Refresh));
}
[Fact]
public void RecastsWhenAStrongerTierIsKnownThanTheOneInForce()
{
// The whole point of tracking tier rather than mere presence: a
// level-1 buff in force must not block casting the level-7 one.
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(1, family: 10, tier: 1, seconds: 900) };
var plan = BuffPlan.Build(known, active, Refresh);
Assert.Single(plan);
Assert.Equal(2u, plan[0].SpellId);
}
[Fact]
public void RefreshesABuffThatIsAboutToExpire()
{
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(2, family: 10, tier: 7, seconds: 5) };
var plan = BuffPlan.Build(known, active, Refresh);
Assert.Single(plan);
Assert.Equal(2u, plan[0].SpellId);
}
[Fact]
public void TreatsFamilyZeroSpellsIndividually()
{
// Family 0 is retail's "does not stack" bucket. Collapsing it by family
// would silently drop every such buff but one, and they are unrelated
// spells that all need casting.
var known = new[]
{
Spell(101, family: 0, tier: 1),
Spell(102, family: 0, tier: 1),
Spell(103, family: 0, tier: 1),
};
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
Assert.Equal(3, plan.Count);
}
[Fact]
public void SkipsAnIndividuallyActiveFamilyZeroSpell()
{
var known = new[] { Spell(101, family: 0, tier: 1), Spell(102, family: 0, tier: 1) };
var active = new[] { Active(101, family: 0, tier: 1, seconds: 900) };
var plan = BuffPlan.Build(known, active, Refresh);
Assert.Single(plan);
Assert.Equal(102u, plan[0].SpellId);
}
[Fact]
public void OrdersCheapestFirstSoAPartialPassLandsMoreBuffs()
{
var known = new[]
{
Spell(1, family: 10, tier: 1, mana: 500),
Spell(2, family: 20, tier: 1, mana: 5),
Spell(3, family: 30, tier: 1, mana: 50),
};
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
Assert.Equal(new uint[] { 2, 3, 1 }, plan.Select(s => s.SpellId).ToArray());
}
[Fact]
public void IsStableAcrossRepeatedBuilds()
{
// The tick loop rebuilds the plan every pass; an unstable order would
// make it re-cast the same spell while starving another.
var known = new[]
{
Spell(1, family: 10, tier: 1, mana: 20),
Spell(2, family: 20, tier: 1, mana: 20),
Spell(3, family: 30, tier: 1, mana: 20),
};
var first = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
var second = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
Assert.Equal(
first.Select(s => s.SpellId).ToArray(),
second.Select(s => s.SpellId).ToArray());
}
[Fact]
public void EmptySpellbookProducesNoPlan()
{
Assert.Empty(BuffPlan.Build(
Array.Empty<PluginSpellInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Refresh));
}
}

View file

@ -0,0 +1,113 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"coverlet.collector": {
"type": "Direct",
"requested": "[6.0.4, )",
"resolved": "6.0.4",
"contentHash": "lkhqpF8Pu2Y7IiN7OntbsTtdbpR1syMsm2F3IgX6ootA4ffRqWL5jF7XipHuZQTdVuWG/gVAAcf8mjk8Tz0xPg=="
},
"Microsoft.NET.Test.Sdk": {
"type": "Direct",
"requested": "[17.14.1, )",
"resolved": "17.14.1",
"contentHash": "HJKqKOE+vshXra2aEHpi2TlxYX7Z9VFYkr+E5rwEvHC8eIXiyO+K9kNm8vmNom3e2rA56WqxU+/N9NJlLGXsJQ==",
"dependencies": {
"Microsoft.CodeCoverage": "17.14.1",
"Microsoft.TestPlatform.TestHost": "17.14.1"
}
},
"xunit": {
"type": "Direct",
"requested": "[2.9.3, )",
"resolved": "2.9.3",
"contentHash": "TlXQBinK35LpOPKHAqbLY4xlEen9TBafjs0V5KnA4wZsoQLQJiirCR4CbIXvOH8NzkW4YeJKP5P/Bnrodm0h9Q==",
"dependencies": {
"xunit.analyzers": "1.18.0",
"xunit.assert": "2.9.3",
"xunit.core": "[2.9.3]"
}
},
"xunit.runner.visualstudio": {
"type": "Direct",
"requested": "[3.1.4, )",
"resolved": "3.1.4",
"contentHash": "5mj99LvCqrq3CNi06xYdyIAXOEh+5b33F2nErCzI5zWiDdLHXiPXEWFSUAF8zlIv0ZWqjZNCwHTQeAPYbF3pCg=="
},
"Microsoft.CodeCoverage": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
"Microsoft.TestPlatform.ObjectModel": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "xTP1W6Mi6SWmuxd3a+jj9G9UoC850WGwZUps1Wah9r1ZxgXhdJfj1QqDLJkFjHDCvN42qDL2Ps5KjQYWUU0zcQ=="
},
"Microsoft.TestPlatform.TestHost": {
"type": "Transitive",
"resolved": "17.14.1",
"contentHash": "d78LPzGKkJwsJXAQwsbJJ7LE7D1wB+rAyhHHAaODF+RDSQ0NgMjDFkSA1Djw18VrxO76GlKAjRUhl+H8NL8Z+Q==",
"dependencies": {
"Microsoft.TestPlatform.ObjectModel": "17.14.1",
"Newtonsoft.Json": "13.0.3"
}
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"xunit.abstractions": {
"type": "Transitive",
"resolved": "2.0.3",
"contentHash": "pot1I4YOxlWjIb5jmwvvQNbTrZ3lJQ+jUGkGjWE3hEFM0l5gOnBWS+H3qsex68s5cO52g+44vpGzhAt+42vwKg=="
},
"xunit.analyzers": {
"type": "Transitive",
"resolved": "1.18.0",
"contentHash": "OtFMHN8yqIcYP9wcVIgJrq01AfTxijjAqVDy/WeQVSyrDC1RzBWeQPztL49DN2syXRah8TYnfvk035s7L95EZQ=="
},
"xunit.assert": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "/Kq28fCE7MjOV42YLVRAJzRF0WmEqsmflm0cfpMjGtzQ2lR5mYVj1/i0Y8uDAOLczkL3/jArrwehfMD0YogMAA=="
},
"xunit.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "BiAEvqGvyme19wE0wTKdADH+NloYqikiU0mcnmiNyXaF9HyHmE6sr/3DC5vnBkgsWaE6yPyWszKSPSApWdRVeQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]",
"xunit.extensibility.execution": "[2.9.3]"
}
},
"xunit.extensibility.core": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "kf3si0YTn2a8J8eZNb+zFpwfoyvIrQ7ivNk5ZYA5yuYk1bEtMe4DxJ2CF/qsRgmEnDr7MnW1mxylBaHTZ4qErA==",
"dependencies": {
"xunit.abstractions": "2.0.3"
}
},
"xunit.extensibility.execution": {
"type": "Transitive",
"resolved": "2.9.3",
"contentHash": "yMb6vMESlSrE3Wfj7V6cjQ3S4TXdXpRqYeNEI3zsX31uTsGMJjEw6oD5F5u1cHnMptjhEECnmZSsPxB6ChZHDQ==",
"dependencies": {
"xunit.extensibility.core": "[2.9.3]"
}
},
"acdream.plugin.abstractions": {
"type": "Project"
},
"acdream.plugins.mosstank": {
"type": "Project",
"dependencies": {
"AcDream.Plugin.Abstractions": "[1.0.0, )"
}
}
}
}
}