acdream/src/AcDream.Plugins.MossTank/VitalPlan.cs
Erik 54005a864c feat(mosstank): cast back-to-back, and a settings view for the thresholds
Three things from the first working buff pass.

**Pacing.** Casts were three seconds apart because of a fixed interval I added
before there was a real busy signal. There is one now -- the shared busy count,
incremented by the cast and decremented by the server's UseDone -- so the
interval is gone and the server is the only throttle. MossTank casts the moment
the previous action is acknowledged, which is the spam behaviour VTank has. The
stall timeout moved 25s -> 30s so a slow server does not read as a stall.

**Stamina to Mana was surprising.** VTank does convert vitals by default
(Recharge-*-Mana), so the behaviour is right, but a buff pass quietly spending
your stamina is not something to discover by watching. It is now a setting,
with its own thresholds, and can be turned off outright.

**Settings view.** Spell difficulty margin, rebuff time, the three vital
thresholds, and three toggles. Two details worth recording:

* The difficulty margin is SIGNED, per the VTank wiki: "a positive number
  raises the skill necessary to cast spells, a negative number lowers it. To
  attempt higher spells at a low level use a negative number." So the range
  spans -100..+100 rather than starting at zero.
* It is a second registered panel with a complementary visible binding rather
  than a tab control. Two panels and an Action need no new markup vocabulary,
  and only one is ever on screen.

Adjuster buttons rather than typed fields: buttons are proven in plugin markup,
while an editable UiField would need keyboard routing plumbed through to plugin
panels first. Worth doing, but not as a side quest inside this change.

Also fixed: the App copy target names plugin files explicitly, so the new
markup would have been left out of the plugin directory and the settings panel
would have failed to load at runtime with the build perfectly green. Caught by
listing the output rather than trusting the build.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 18:51:15 +02:00

121 lines
4.2 KiB
C#

using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>What MossTank wants to do about the character's vitals right now.</summary>
public enum VitalAction
{
None = 0,
/// <summary>Convert stamina into mana.</summary>
StaminaToMana,
/// <summary>Restore stamina, so stamina-to-mana has something to convert.</summary>
Revitalize,
}
/// <summary>Thresholds for vital upkeep, following VTank's Recharge-* settings.</summary>
public sealed class VitalSettings
{
/// <summary>
/// Whether to convert vitals at all. VTank does this by default through its
/// Recharge-* thresholds, but it is surprising the first time a buff pass
/// spends your stamina, so it is worth being able to turn off.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>Convert stamina to mana below this fraction of max mana.</summary>
public double ManaFloor { get; set; } = 0.50;
/// <summary>Stop converting once mana is back above this fraction.</summary>
public double ManaTarget { get; set; } = 0.85;
/// <summary>Refuse to drain stamina below this fraction — the conversion
/// takes half your stamina, and stranding the character at zero is worse
/// than being short of mana.</summary>
public double StaminaFloor { get; set; } = 0.35;
}
/// <summary>
/// Picks the vital-upkeep spell to cast, if any.
/// </summary>
/// <remarks>
/// <para>
/// The loop the user asked for: when mana runs low, convert stamina into mana;
/// when that leaves stamina low, restore stamina with Revitalize, which lets
/// the conversion continue.
/// </para>
/// <para>
/// <b>These spells cannot be identified by family.</b> Retail groups the vital
/// transfers by <em>source</em> vital, so family 89 contains both "Stamina to
/// Health" and "Stamina to Mana", and family 87 both "Health to Mana" and
/// "Health to Stamina". Picking the strongest tier in a family would therefore
/// convert into the wrong vital roughly half the time. They are identified by
/// their retail name stem instead, which is stable and comes from the same
/// spell table.
/// </para>
/// </remarks>
public static class VitalPlan
{
public const string StaminaToManaStem = "Stamina to Mana";
public const string RevitalizeStem = "Revitalize";
public static VitalAction Decide(ICharacterInfo character, VitalSettings settings)
{
if (!settings.Enabled)
return VitalAction.None;
double mana = Fraction(character.CurrentMana, character.MaxMana);
double stamina = Fraction(character.CurrentStamina, character.MaxStamina);
// Unknown vitals (no session, or nothing published yet) must not be
// read as "empty" — that would cast on a character that is fine.
if (character.MaxMana == 0 || character.MaxStamina == 0)
return VitalAction.None;
if (mana >= settings.ManaTarget)
return VitalAction.None;
if (mana < settings.ManaFloor)
{
return stamina > settings.StaminaFloor
? VitalAction.StaminaToMana
: VitalAction.Revitalize;
}
return VitalAction.None;
}
/// <summary>
/// The strongest castable spell whose name contains <paramref name="stem"/>.
/// </summary>
public static bool TryFind(
IReadOnlyList<PluginSpellInfo> known,
string stem,
IReadOnlyDictionary<uint, uint> skillLevels,
int skillExcess,
out PluginSpellInfo pick)
{
pick = default;
bool found = false;
foreach (PluginSpellInfo spell in known)
{
if (spell.Name.IndexOf(stem, StringComparison.OrdinalIgnoreCase) < 0)
continue;
if (spell.School != 0
&& skillLevels.TryGetValue(spell.School, out uint level)
&& level < spell.Difficulty + skillExcess)
{
continue;
}
if (!found || spell.Tier > pick.Tier)
{
pick = spell;
found = true;
}
}
return found;
}
private static double Fraction(uint current, uint max) =>
max == 0 ? 1.0 : (double)current / max;
}