Reworks MossTank against user feedback and the Virindi Tank feature docs
(virindi.net is reachable again over https with a self-signed cert; the
research doc's "unreachable" note is stale).
VTank's stated default is the spec: "automatically buffs every Attribute and
Skill you have trained", and "all buff spells are recast when they go below 5
minutes". The previous pass buffed the whole spellbook and refreshed at 60s;
both are corrected.
The hard problem was working out WHICH stat each buff raises. The client's
spell table has no such link -- it arrives from the server with the
enchantment -- and the naming is too irregular to infer: Invulnerability
raises Melee Defense, Impregnability raises Missile Defense, Fealty raises
Loyalty, Sprint raises Run, Arcane Enlightenment raises Arcane Lore, and the
line called Willpower raises the attribute named Self. Any name-matching
scheme dies on that last one.
Retail states it outright in each spell's own description ("Increases the
caster's Life Magic skill by 10 points"), so BuffProfile derives the whole
mapping from shipped data at runtime. It also carries the one alias the data
needs: the spell text says "Assess Monster" where the skill table says "Assess
Creature", and without that the skill silently never matches.
Two data facts that would each have caused a real bug, found by dumping the
spell table rather than assuming:
* Family is NOT a spell-line identity in general. Retail groups the
instantaneous vital transfers by SOURCE vital, so family 89 holds both
"Stamina to Health" and "Stamina to Mana". Picking the strongest tier in a
family would convert into the wrong vital about half the time. Buff lines
group by family (correct for duration buffs, which is retail's own stacking
bucket); the conversions are found by name stem instead.
* Instantaneous spells have no duration and must be excluded from buff lines
entirely, or they are treated as buffs that never appear to land.
Tier selection now follows the character's skill in the casting school against
the spell's difficulty (VTank's SpellDiffExcessThreshold-Buff), which is why
PluginSpellInfo gained School as a SKILL id -- MagicSchool is retail's 1-5
school enum, not something a character trains.
Mana upkeep is the loop asked for: convert stamina to mana when mana is low,
Revitalize when that leaves stamina too low to convert, and refuse to drain
stamina past a floor. Unknown vitals read as zero and are treated as "no
information" rather than "empty", so it will not cast on a healthy character.
Panel no longer shows at character select. IsAvailable is now the runtime's
own lifecycle state rather than a proxy, and markup gained visible="{Binding}"
plus UiElement.VisibleSource -- evaluated before the visible gate, because
TickSelfAndChildren returns early when hidden and an element could otherwise
never un-hide itself.
Also: a generated SpellId enum of all 6,266 spells (tools/SpellDump --enum),
generated from portal.dat rather than copied, so it cannot drift and carries
no third-party licence; skill and spell names now come from the retail tables
for display; and the Buff click logs unconditionally, so "nothing happened"
can be told apart from "the click never arrived".
Solution builds clean; 14,433 tests pass on the standard hermetic lane filter,
0 failures, including 21 covering the buff profile, tier selection and mana
loop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
7 KiB
C#
212 lines
7 KiB
C#
namespace AcDream.Plugin.Abstractions;
|
|
|
|
/// <summary>How far a character has taken a skill.</summary>
|
|
public enum PluginSkillTraining
|
|
{
|
|
Unknown = 0,
|
|
Untrained,
|
|
Trained,
|
|
Specialized,
|
|
}
|
|
|
|
/// <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.
|
|
/// </remarks>
|
|
/// <param name="Family">
|
|
/// Retail's stacking bucket, and the correct identity for a <em>duration</em>
|
|
/// buff line. It is NOT a safe identity in general: the instantaneous vital
|
|
/// transfers share a family per source vital, so family 89 holds both
|
|
/// "Stamina to Health" and "Stamina to Mana". Group by family only after
|
|
/// filtering to duration buffs.
|
|
/// </param>
|
|
/// <param name="Tier">
|
|
/// Retail's spell <c>Generation</c> — the roman-numeral level. Higher is
|
|
/// stronger within a family.
|
|
/// </param>
|
|
/// <param name="School">
|
|
/// Skill id of the magic school that casts this spell, so a plugin can weigh
|
|
/// the character's skill in that school against <paramref name="Difficulty"/>.
|
|
/// </param>
|
|
/// <param name="Description">
|
|
/// Retail's own spell description. Load-bearing rather than cosmetic: the
|
|
/// client's spell table carries no link between a spell and the stat it
|
|
/// raises — that arrives from the server with the enchantment — but the
|
|
/// description states it in words ("Increases the caster's Life Magic skill by
|
|
/// 10 points"), so a plugin can derive the mapping from shipped data instead of
|
|
/// hard-coding one.
|
|
/// </param>
|
|
public readonly record struct PluginSpellInfo(
|
|
uint SpellId,
|
|
string Name,
|
|
uint Family,
|
|
int Tier,
|
|
int Difficulty,
|
|
int ManaCost,
|
|
float DurationSeconds,
|
|
uint School,
|
|
string Description,
|
|
bool IsSelfTargeted,
|
|
bool IsBeneficial);
|
|
|
|
/// <summary>One enchantment currently in force on the local player.</summary>
|
|
public readonly record struct PluginActiveEnchantment(
|
|
uint SpellId,
|
|
uint Family,
|
|
int Tier,
|
|
double SecondsRemaining);
|
|
|
|
/// <summary>One of the character's skills, named from the retail skill table.</summary>
|
|
public readonly record struct PluginSkillInfo(
|
|
uint SkillId,
|
|
string Name,
|
|
PluginSkillTraining Training,
|
|
uint Current);
|
|
|
|
/// <summary>One primary attribute. <paramref name="Kind"/> is 0..5.</summary>
|
|
public readonly record struct PluginAttributeInfo(
|
|
int Kind,
|
|
string Name,
|
|
uint Current);
|
|
|
|
/// <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,
|
|
/// <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 CurrentHealth { get; }
|
|
uint MaxHealth { get; }
|
|
uint CurrentStamina { get; }
|
|
uint MaxStamina { get; }
|
|
uint CurrentMana { get; }
|
|
uint MaxMana { get; }
|
|
|
|
/// <summary>Skills the character has, with training state and current level.</summary>
|
|
IReadOnlyList<PluginSkillInfo> Skills { get; }
|
|
|
|
/// <summary>The six primary attributes.</summary>
|
|
IReadOnlyList<PluginAttributeInfo> Attributes { 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; }
|
|
|
|
bool TryGetSkill(uint skillId, out PluginSkillInfo skill);
|
|
}
|
|
|
|
/// <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.
|
|
/// </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 several.
|
|
/// </summary>
|
|
public interface IAutomationSurface
|
|
{
|
|
/// <summary>
|
|
/// <see langword="false"/> on hosts that never bind a live session, and
|
|
/// while a graphical host is between sessions — including at character
|
|
/// select, which is what lets a plugin panel stay hidden until there is a
|
|
/// character to act on.
|
|
/// </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 CurrentHealth => 0;
|
|
public uint MaxHealth => 0;
|
|
public uint CurrentStamina => 0;
|
|
public uint MaxStamina => 0;
|
|
public uint CurrentMana => 0;
|
|
public uint MaxMana => 0;
|
|
|
|
public IReadOnlyList<PluginSkillInfo> Skills { get; } = Array.Empty<PluginSkillInfo>();
|
|
public IReadOnlyList<PluginAttributeInfo> Attributes { get; } =
|
|
Array.Empty<PluginAttributeInfo>();
|
|
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; } =
|
|
Array.Empty<PluginActiveEnchantment>();
|
|
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; } =
|
|
Array.Empty<PluginSpellInfo>();
|
|
|
|
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
|
|
{
|
|
skill = default;
|
|
return false;
|
|
}
|
|
|
|
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;
|
|
}
|