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:
parent
690f21889e
commit
9d1117b923
27 changed files with 1546 additions and 10 deletions
155
src/AcDream.Plugin.Abstractions/Automation.cs
Normal file
155
src/AcDream.Plugin.Abstractions/Automation.cs
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue