feat(mosstank): buff trained skills and attributes, pick tiers by skill, manage mana

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>
This commit is contained in:
Erik 2026-08-20 18:09:02 +02:00
parent 9d1117b923
commit 17ebfc434d
18 changed files with 14052 additions and 279 deletions

View file

@ -1,6 +1,7 @@
using AcDream.Core.Player;
using AcDream.Core.Spells;
using AcDream.Plugin.Abstractions;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Plugins;
@ -12,15 +13,15 @@ namespace AcDream.App.Plugins;
/// <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.
/// <c>GameRuntime</c>, whose gameplay owners are stable for its lifetime.
/// <see cref="IsAvailable"/> tracks whether a character is actually in world,
/// which is what lets a plugin panel hide itself at character select rather
/// than sitting on top of the login screen.
/// </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.
/// The snapshot lists are rebuilt on Spellbook change notifications rather than
/// per read: 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
@ -28,21 +29,45 @@ internal sealed class AppAutomationSurface
{
private readonly object _gate = new();
private GameRuntime? _runtime;
private RuntimeCharacterState? _character;
private RuntimeSpellCastState? _cast;
private Spellbook? _spellbook;
private IReadOnlyDictionary<uint, string> _skillNames =
new Dictionary<uint, string>();
private bool _disposed;
private IReadOnlyList<PluginSpellInfo> _knownSelfBuffs = Array.Empty<PluginSpellInfo>();
private IReadOnlyList<PluginActiveEnchantment> _enchantments =
Array.Empty<PluginActiveEnchantment>();
/// <summary>
/// Retail's six primary attributes in <c>LocalPlayerState.AttributeKind</c>
/// order. The names are the ones retail's own spell descriptions use — note
/// the sixth is <b>Self</b>, whose buff line is confusingly named Willpower.
/// </summary>
private static readonly string[] AttributeNames =
["Strength", "Endurance", "Quickness", "Coordination", "Focus", "Self"];
/// <summary>
/// True only while a character is actually in world. The runtime's own
/// lifecycle state is the signal: at character select the gameplay owners
/// exist but there is no character to act on, and a plugin panel keying off
/// this can stay hidden until there is.
/// </summary>
public bool IsAvailable
{
get
{
GameRuntime? runtime;
lock (_gate)
return !_disposed && _character is not null && _cast is not null;
{
if (_disposed || _character is null || _cast is null)
return false;
runtime = _runtime;
}
return runtime is not null
&& runtime.Lifecycle.State == RuntimeLifecycleState.InWorld;
}
}
@ -50,9 +75,11 @@ internal sealed class AppAutomationSurface
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)
/// <summary>Bind the surface to the runtime's gameplay owners.</summary>
public void Bind(
GameRuntime runtime, RuntimeCharacterState character, RuntimeSpellCastState cast)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(cast);
@ -62,6 +89,7 @@ internal sealed class AppAutomationSurface
if (_disposed)
return;
DetachLocked();
_runtime = runtime;
_character = character;
_cast = cast;
_spellbook = spellbook;
@ -73,7 +101,18 @@ internal sealed class AppAutomationSurface
RebuildEnchantments();
}
/// <summary>Release the session's owners; reads go inert until the next bind.</summary>
/// <summary>
/// Supply retail skill names, read once from portal.dat's SkillTable. Kept
/// separate from <see cref="Bind"/> because content opens later than the
/// runtime owners do.
/// </summary>
public void BindSkillNames(IReadOnlyDictionary<uint, string> skillNames)
{
ArgumentNullException.ThrowIfNull(skillNames);
lock (_gate)
_skillNames = skillNames;
}
public void Unbind()
{
lock (_gate)
@ -92,6 +131,7 @@ internal sealed class AppAutomationSurface
_spellbook = null;
_character = null;
_cast = null;
_runtime = null;
}
private void OnSpellbookChanged() => RebuildSpellbook();
@ -119,8 +159,6 @@ internal sealed class AppAutomationSurface
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)
@ -168,37 +206,143 @@ internal sealed class AppAutomationSurface
meta.Difficulty,
meta.ManaCost,
meta.Duration,
SchoolSkillId(meta.SchoolId),
meta.Description,
meta.IsSelfTargeted,
meta.IsBeneficial);
/// <summary>
/// Magic school to the SKILL id that governs it. <c>MagicSchool</c> is
/// retail's 1-5 school enum, not a skill id, and a plugin weighing spell
/// difficulty needs the skill the character actually trains. Ids are the
/// retail skill table's own (portal.dat 0x0E000004).
/// </summary>
private static uint SchoolSkillId(MagicSchool school) => school switch
{
MagicSchool.CreatureEnchantment => 31u,
MagicSchool.ItemEnchantment => 32u,
MagicSchool.LifeMagic => 33u,
MagicSchool.WarMagic => 34u,
MagicSchool.VoidMagic => 43u,
_ => 0u,
};
// ── ICharacterInfo ────────────────────────────────────────────────────
public bool IsInWorld => IsAvailable;
public uint CurrentMana => Vital(out uint current, out _) ? current : 0u;
public uint CurrentHealth => Vital(LocalPlayerState.VitalKind.Health).Current;
public uint MaxHealth => Vital(LocalPlayerState.VitalKind.Health).Maximum;
public uint CurrentStamina => Vital(LocalPlayerState.VitalKind.Stamina).Current;
public uint MaxStamina => Vital(LocalPlayerState.VitalKind.Stamina).Maximum;
public uint CurrentMana => Vital(LocalPlayerState.VitalKind.Mana).Current;
public uint MaxMana => Vital(LocalPlayerState.VitalKind.Mana).Maximum;
public uint MaxMana => Vital(out _, out uint maximum) ? maximum : 0u;
private bool Vital(out uint current, out uint maximum)
private (uint Current, uint Maximum) Vital(LocalPlayerState.VitalKind kind)
{
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))
if (character is null
|| !character.View.TryGetVital((int)kind, out var vital))
{
return false;
return (0, 0);
}
current = vital.Current;
maximum = vital.Maximum;
return true;
return (vital.Current, vital.Maximum);
}
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments => _enchantments;
public IReadOnlyList<PluginSkillInfo> Skills
{
get
{
RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names;
lock (_gate)
{
character = _character;
names = _skillNames;
}
if (character is null || names.Count == 0)
return Array.Empty<PluginSkillInfo>();
var built = new List<PluginSkillInfo>(names.Count);
foreach (KeyValuePair<uint, string> pair in names)
{
if (TryProjectSkill(character, pair.Key, pair.Value, out PluginSkillInfo skill))
built.Add(skill);
}
built.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
return built;
}
}
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
RuntimeCharacterState? character;
IReadOnlyDictionary<uint, string> names;
lock (_gate)
{
character = _character;
names = _skillNames;
}
if (character is not null)
{
string name = names.TryGetValue(skillId, out string? n) ? n : string.Empty;
return TryProjectSkill(character, skillId, name, out skill);
}
skill = default;
return false;
}
private static bool TryProjectSkill(
RuntimeCharacterState character, uint skillId, string name,
out PluginSkillInfo skill)
{
if (!character.View.TryGetSkill(skillId, out var snapshot))
{
skill = default;
return false;
}
skill = new PluginSkillInfo(
skillId, name, Training(snapshot.Status), snapshot.CurrentLevel);
return true;
}
/// <summary>
/// Retail's <c>SKILL_ADVANCEMENT_CLASS</c>: 0 undef, 1 untrained,
/// 2 trained, 3 specialized. Mapped to a plugin-facing enum rather than
/// leaked as a raw number so a plugin need not carry retail's encoding.
/// </summary>
private static PluginSkillTraining Training(uint status) => status switch
{
1 => PluginSkillTraining.Untrained,
2 => PluginSkillTraining.Trained,
3 => PluginSkillTraining.Specialized,
_ => PluginSkillTraining.Unknown,
};
public IReadOnlyList<PluginAttributeInfo> Attributes
{
get
{
RuntimeCharacterState? character;
lock (_gate)
character = _character;
if (character is null)
return Array.Empty<PluginAttributeInfo>();
var built = new List<PluginAttributeInfo>(AttributeNames.Length);
for (int kind = 0; kind < AttributeNames.Length; kind++)
{
if (character.View.TryGetAttribute(kind, out var attribute))
built.Add(new PluginAttributeInfo(
kind, AttributeNames[kind], attribute.Current));
}
return built;
}
}
// ── ISpellCatalog ─────────────────────────────────────────────────────
public IReadOnlyList<PluginSpellInfo> KnownSelfBuffs => _knownSelfBuffs;
@ -238,7 +382,7 @@ internal sealed class AppAutomationSurface
cast = _cast;
spellbook = _spellbook;
}
if (cast is null || spellbook is null)
if (cast is null || spellbook is null || !IsAvailable)
return PluginCastGate.Unavailable;
if (!spellbook.Knows(spellId))
return PluginCastGate.NotKnown;

View file

@ -660,7 +660,7 @@ public sealed class GameWindow :
// 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);
_automation?.Bind(_runtime, _runtime.CharacterOwner, _runtime.ActionOwner.SpellCast);
_localPlayerIdentity = new AcDream.App.Input.LocalPlayerIdentityState(
_runtime.PlayerIdentity);
_updateFrameClock = new AcDream.App.Update.UpdateFrameClock(
@ -1313,6 +1313,20 @@ 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,

View file

@ -42,6 +42,22 @@ public static class MarkupDocument
panel.ResizeY = resize is "y" or "both";
}
// Panel-level visibility binding: lets a plugin keep its window out of
// the way until it has something to act on (e.g. hidden at character
// select, shown in world).
string? visible = (string?)root.Attribute("visible");
if (visible is not null && IsBinding(visible))
{
PropertyInfo? flag = binding.GetType().GetProperty(visible[1..^1]);
if (flag is null || flag.PropertyType != typeof(bool))
{
throw new FormatException(
$"<panel visible=\"{visible}\"> did not resolve to a bool property "
+ $"on {binding.GetType().Name}");
}
panel.VisibleSource = () => flag.GetValue(binding) is true;
}
string? title = (string?)root.Attribute("title");
if (!string.IsNullOrEmpty(title))
{
@ -118,6 +134,11 @@ public static class MarkupDocument
Height = F(el, "h"),
Text = (string?)el.Attribute("text") ?? string.Empty,
};
// A bound caption lets the button re-label itself (Buff /
// Stop) from the same binding object.
string? caption = (string?)el.Attribute("text");
if (caption is not null && IsBinding(caption))
button.TextSource = BindString(caption, binding);
if (el.Attribute("color") is not null)
button.TextColor = Color((string?)el.Attribute("color"));
if (onClick is not null)

View file

@ -267,6 +267,13 @@ public abstract class UiElement
/// </summary>
public bool ClickThrough { get; set; }
/// <summary>
/// Optional live visibility reader, evaluated once per tick. Markup
/// <c>visible="{Binding}"</c> uses this so a panel can show and hide itself
/// from its binding object's state without the owner touching UI objects.
/// </summary>
public Func<bool>? VisibleSource { get; set; }
/// <summary>
/// If true, <see cref="UiRoot"/> will set focus here on click,
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
@ -679,6 +686,11 @@ public abstract class UiElement
internal void TickSelfAndChildren(double dt)
{
// Evaluated before the Visible gate on purpose: the gate returns early
// for a hidden element, so a source read after it could turn an element
// off but never back on.
if (VisibleSource is { } visibility)
Visible = visibility();
if (!Visible) return;
OnTick(dt);
for (int i = 0; i < _children.Count; i++)

View file

@ -97,6 +97,13 @@ public class UiSimpleButton : UiPanel
{
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
/// <summary>
/// Optional live caption reader, preferred over <see cref="Text"/> when
/// set, so a markup-bound button can change its own label (Buff / Stop)
/// without the binding object touching UI objects.
/// </summary>
public Func<string?>? TextSource { get; set; }
public event System.Action? Click;
public UiSimpleButton()
@ -118,12 +125,13 @@ public class UiSimpleButton : UiPanel
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
if (Text.Length == 0 || ctx.DefaultFont is null) return;
string caption = TextSource?.Invoke() ?? Text;
if (caption.Length == 0 || ctx.DefaultFont is null) return;
float textW = ctx.DefaultFont.MeasureWidth(Text);
float textW = ctx.DefaultFont.MeasureWidth(caption);
float tx = (Width - textW) * 0.5f;
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
ctx.DrawString(Text, tx, ty, TextColor);
ctx.DrawString(caption, tx, ty, TextColor);
}
}

View file

@ -1,5 +1,14 @@
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>
@ -8,18 +17,31 @@ namespace AcDream.Plugin.Abstractions;
/// 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.
/// Decal's.
/// </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.
/// 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,
@ -28,20 +50,31 @@ public readonly record struct PluginSpellInfo(
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>
/// <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>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
{
@ -49,8 +82,6 @@ public enum PluginCastGate
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>
@ -61,14 +92,27 @@ public enum PluginCastGate
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>
@ -76,9 +120,7 @@ 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.
/// beneficial.
/// </summary>
IReadOnlyList<PluginSpellInfo> KnownSelfBuffs { get; }
@ -101,13 +143,15 @@ public interface IMagicCommands
/// <summary>
/// The automation surface: reads, spell data, and commands, grouped so
/// <see cref="IPluginHost"/> grows by one member rather than three.
/// <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.
/// 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; }
@ -135,14 +179,27 @@ public sealed class NoOpAutomationSurface
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;

View file

@ -2,108 +2,154 @@ 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>Settings that shape a buff pass. Defaults follow Virindi Tank's.</summary>
public sealed record BuffSettings
{
/// <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.
/// VTank recasts buffs once they drop below five minutes remaining
/// ("all buff spells are recast when they go below 5 minutes").
/// </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>();
public double RebuffWhenUnderSeconds { get; init; } = 300.0;
foreach (PluginSpellInfo spell in knownSelfBuffs)
/// <summary>
/// How far the casting skill must exceed a spell's difficulty before the
/// tier is considered reliable — VTank's
/// <c>SpellDiffExcessThreshold-Buff</c>.
/// </summary>
public int SkillExcessOverDifficulty { get; init; } = 10;
/// <summary>Buff every attribute (VTank's default).</summary>
public bool BuffAttributes { get; init; } = true;
/// <summary>
/// Buff trained and specialised skills only — VTank's stated default:
/// "automatically buffs every Attribute and Skill you have trained".
/// </summary>
public bool BuffTrainedSkillsOnly { get; init; } = true;
}
/// <summary>
/// Chooses which buffs to cast, at which tier, in which order.
/// </summary>
/// <remarks>
/// A pure function of (buff lines, character state) so the policy can be tested
/// without a session. This is the part that stays in plugin-land: the host
/// supplies spell data and a cast primitive, MossTank decides.
/// </remarks>
public static class BuffPlan
{
public static List<PluginSpellInfo> Build(
IReadOnlyList<BuffLine> lines,
IReadOnlyList<PluginSkillInfo> skills,
IReadOnlyList<PluginAttributeInfo> attributes,
IReadOnlyList<PluginActiveEnchantment> active,
BuffSettings settings)
{
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
StringComparer.OrdinalIgnoreCase);
foreach (PluginSkillInfo skill in skills)
{
if (spell.Family == 0)
if (!settings.BuffTrainedSkillsOnly
|| skill.Training is PluginSkillTraining.Trained
or PluginSkillTraining.Specialized)
{
unstackable.Add(spell);
continue;
}
if (!bestByFamily.TryGetValue(spell.Family, out PluginSpellInfo held)
|| spell.Tier > held.Tier)
{
bestByFamily[spell.Family] = spell;
trainedSkills[skill.Name] = skill;
}
}
// 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>();
var attributeNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (PluginAttributeInfo attribute in attributes)
attributeNames.Add(attribute.Name);
// Strongest in-force tier per family, and its remaining time.
var inForce = new Dictionary<uint, (int Tier, double Seconds)>();
foreach (PluginActiveEnchantment enchantment in active)
{
activeSpellSeconds[enchantment.SpellId] = enchantment.SecondsRemaining;
if (enchantment.Family == 0)
continue;
if (!activeByFamily.TryGetValue(enchantment.Family, out var held)
if (!inForce.TryGetValue(enchantment.Family, out var held)
|| enchantment.Tier > held.Tier)
{
activeByFamily[enchantment.Family] =
inForce[enchantment.Family] =
(enchantment.Tier, enchantment.SecondsRemaining);
}
}
var skillLevels = new Dictionary<uint, uint>();
foreach (PluginSkillInfo skill in skills)
skillLevels[skill.SkillId] = skill.Current;
var plan = new List<PluginSpellInfo>();
foreach (PluginSpellInfo candidate in bestByFamily.Values)
foreach (BuffLine line in lines)
{
if (!activeByFamily.TryGetValue(candidate.Family, out var inForce))
bool wanted = line.Kind switch
{
plan.Add(candidate);
BuffTargetKind.Attribute =>
settings.BuffAttributes && attributeNames.Contains(line.TargetName),
BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName),
_ => false,
};
if (!wanted)
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)
if (!TryPickTier(line, skillLevels, settings, out PluginSpellInfo pick))
continue;
if (inForce.TryGetValue(line.Family, out var held)
&& held.Tier >= pick.Tier
&& held.Seconds >= settings.RebuffWhenUnderSeconds)
{
plan.Add(candidate);
continue; // already covered at this strength, and not expiring
}
plan.Add(pick);
}
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.
// Cheapest first: if mana runs out mid-pass, more buffs land 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;
}
/// <summary>
/// The strongest tier the character's skill in that school can carry.
/// </summary>
/// <remarks>
/// Tiers are pre-sorted strongest-first, so the first one clearing the
/// difficulty threshold is the answer. When no school skill is known the
/// threshold cannot be evaluated, and the weakest tier is chosen rather
/// than none — a buff that lands beats a buff that was never attempted.
/// </remarks>
public static bool TryPickTier(
BuffLine line,
IReadOnlyDictionary<uint, uint> skillLevels,
BuffSettings settings,
out PluginSpellInfo pick)
{
pick = default;
if (line.Tiers.Count == 0)
return false;
foreach (PluginSpellInfo tier in line.Tiers)
{
if (tier.School == 0 || !skillLevels.TryGetValue(tier.School, out uint level))
continue;
if (level >= tier.Difficulty + settings.SkillExcessOverDifficulty)
{
pick = tier;
return true;
}
}
PluginSpellInfo weakest = line.Tiers[^1];
if (weakest.School != 0 && skillLevels.ContainsKey(weakest.School))
return false; // school known, but even the weakest tier is out of reach
pick = weakest;
return true;
}
}

View file

@ -0,0 +1,119 @@
using System.Text.RegularExpressions;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>What a buff line raises.</summary>
public enum BuffTargetKind
{
Unknown = 0,
Skill,
Attribute,
}
/// <summary>One buff line: a family, what it raises, and its known tiers.</summary>
public sealed record BuffLine(
uint Family,
BuffTargetKind Kind,
string TargetName,
List<PluginSpellInfo> Tiers);
/// <summary>
/// Works out which stat each known self-buff raises, straight from retail data.
/// </summary>
/// <remarks>
/// <para>
/// The client's spell table carries no link between a spell and the stat it
/// modifies — that arrives from the server with the enchantment. But retail
/// writes it in the spell's own description:
/// </para>
/// <code>
/// Increases the caster's Life Magic skill by 10 points.
/// Increases the caster's Strength by 10 points.
/// </code>
/// <para>
/// So the mapping is derived from shipped data rather than hard-coded, which
/// matters because the naming is genuinely irregular and no rule would cover
/// it: <b>Invulnerability</b> raises Melee Defense, <b>Impregnability</b>
/// raises Missile Defense, <b>Fealty</b> raises Loyalty, <b>Sprint</b> raises
/// Run, <b>Arcane Enlightenment</b> raises Arcane Lore, and — the one that
/// would silently poison any name-matching scheme — the spell line called
/// <b>Willpower</b> raises the attribute named <b>Self</b>.
/// </para>
/// </remarks>
public static partial class BuffProfile
{
[GeneratedRegex(
@"^Increases (?:the caster's|your) (?<target>.+?)(?<skill>\s+skill)? by ",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex IncreasesPattern();
/// <summary>
/// Retail's spell text says "Assess Monster" where the skill table says
/// "Assess Creature". Without this the skill silently never matches and its
/// buff is quietly dropped from every plan.
/// </summary>
private static readonly Dictionary<string, string> SkillNameAliases =
new(StringComparer.OrdinalIgnoreCase)
{
["Assess Monster"] = "Assess Creature",
};
/// <summary>
/// Group the character's known self-buffs into buff lines, keeping only
/// lines that raise a stat and last long enough to be worth maintaining.
/// </summary>
public static List<BuffLine> Build(IReadOnlyList<PluginSpellInfo> knownSelfBuffs)
{
var byFamily = new Dictionary<uint, BuffLine>();
foreach (PluginSpellInfo spell in knownSelfBuffs)
{
// Instantaneous spells (the vital transfers, heals) are not buffs;
// they also share families across unrelated lines, so grouping them
// by family would be wrong twice over.
if (spell.DurationSeconds <= 0f)
continue;
if (!TryParseTarget(spell.Description, out BuffTargetKind kind, out string target))
continue;
if (!byFamily.TryGetValue(spell.Family, out BuffLine? line))
{
line = new BuffLine(spell.Family, kind, target, new List<PluginSpellInfo>());
byFamily.Add(spell.Family, line);
}
line.Tiers.Add(spell);
}
foreach (BuffLine line in byFamily.Values)
line.Tiers.Sort(static (a, b) => b.Tier.CompareTo(a.Tier)); // strongest first
return byFamily.Values.ToList();
}
/// <summary>Parse "Increases the caster's X [skill] by N points."</summary>
public static bool TryParseTarget(
string? description, out BuffTargetKind kind, out string target)
{
kind = BuffTargetKind.Unknown;
target = string.Empty;
if (string.IsNullOrEmpty(description))
return false;
Match match = IncreasesPattern().Match(description);
if (!match.Success)
return false;
target = match.Groups["target"].Value.Trim();
if (target.Length == 0)
return false;
if (SkillNameAliases.TryGetValue(target, out string? alias))
target = alias;
// The word "skill" is what separates a skill buff from an attribute
// buff in retail's own wording.
kind = match.Groups["skill"].Success ? BuffTargetKind.Skill : BuffTargetKind.Attribute;
return true;
}
}

View file

@ -6,34 +6,21 @@ namespace AcDream.Plugins.MossTank;
/// 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>
/// Everything here runs on the host's update thread — the button click and the
/// tick both arrive there — so no locking is used, deliberately: a lock would
/// imply a second thread that does not exist.
/// </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 const double StallTimeoutSeconds = 25.0;
private readonly IPluginHost _host;
private readonly BuffSettings _buffSettings = new();
private readonly VitalSettings _vitalSettings = new();
private List<PluginSpellInfo> _plan = new();
private int _planIndex;
@ -48,28 +35,62 @@ internal sealed class MossTankPanel
/// <summary>Bound to the panel's Buff button.</summary>
public Action Buff => StartOrStop;
public string Title => "MossTank";
public string Status => _status;
/// <summary>
/// Bound to the panel's <c>visible</c>. Keeps the window off the character
/// select and login screens, where there is no character to buff.
/// </summary>
public bool IsInWorld => _host.Automation.IsAvailable;
public string ButtonText => _running ? "Stop" : "Buff";
public string Detail
public string Status => _status;
/// <summary>Vitals line, using the same numbers the character panel shows.</summary>
public string Vitals
{
get
{
ICharacterInfo character = _host.Automation.Character;
if (!_host.Automation.IsAvailable)
return string.Empty;
return $"Health {character.CurrentHealth}/{character.MaxHealth}"
+ $" Stam {character.CurrentStamina}/{character.MaxStamina}"
+ $" Mana {character.CurrentMana}/{character.MaxMana}";
}
}
/// <summary>What a buff pass would cover, named from the retail tables.</summary>
public string Coverage
{
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}";
return string.Empty;
int trained = 0;
foreach (PluginSkillInfo skill in automation.Character.Skills)
{
if (skill.Training is PluginSkillTraining.Trained
or PluginSkillTraining.Specialized)
{
trained++;
}
}
int attributes = automation.Character.Attributes.Count;
int lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs).Count;
return $"{attributes} attributes, {trained} trained skills, {lines} buff lines known";
}
}
private void StartOrStop()
{
// Logged unconditionally: "nothing happened when I clicked" is
// ambiguous between the click never arriving and the click arriving and
// declining to act. This line separates those two without guesswork.
_host.Log.Info(
$"MossTank: Buff clicked (running={_running}, inWorld={_host.Automation.IsAvailable})");
if (_running)
{
Stop("Stopped.");
@ -83,24 +104,16 @@ internal sealed class MossTankPanel
return;
}
_plan = BuffPlan.Build(
automation.Spells.KnownSelfBuffs,
automation.Character.ActiveEnchantments,
RefreshWhenUnderSeconds);
_plan = BuildPlan(automation);
_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");
_status = _plan.Count == 0
? "Checking…"
: $"Buffing 0/{_plan.Count}…";
_host.Log.Info($"MossTank: pass started, {_plan.Count} buff(s) queued");
}
private void Stop(string status)
@ -111,6 +124,25 @@ internal sealed class MossTankPanel
_status = status;
}
private List<PluginSpellInfo> BuildPlan(IAutomationSurface automation)
{
List<BuffLine> lines = BuffProfile.Build(automation.Spells.KnownSelfBuffs);
return BuffPlan.Build(
lines,
automation.Character.Skills,
automation.Character.Attributes,
automation.Character.ActiveEnchantments,
_buffSettings);
}
private Dictionary<uint, uint> SkillLevels(IAutomationSurface automation)
{
var levels = new Dictionary<uint, uint>();
foreach (PluginSkillInfo skill in automation.Character.Skills)
levels[skill.SkillId] = skill.Current;
return levels;
}
/// <summary>Driven by <see cref="IEvents.Tick"/> on the host update thread.</summary>
public void OnTick(double elapsedSeconds)
{
@ -130,24 +162,26 @@ internal sealed class MossTankPanel
if (_sinceProgress > StallTimeoutSeconds)
{
Stop($"Stalled after {_castThisPass} cast(s).");
_host.Log.Warn("MossTank: buff pass stalled; stopping");
_host.Log.Warn("MossTank: pass stalled; stopping");
return;
}
if (_sinceLastCast < CastIntervalSeconds || automation.Magic.IsCasting)
return;
// Vitals come first: a buff pass that runs itself out of mana and keeps
// trying is worse than one that pauses to convert stamina.
if (TryVitalUpkeep(automation))
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);
_plan = BuildPlan(automation);
if (_plan.Count == 0)
{
Stop($"Done — {_castThisPass} cast(s).");
_host.Log.Info($"MossTank: buff pass complete ({_castThisPass} cast)");
_host.Log.Info($"MossTank: pass complete ({_castThisPass} cast)");
return;
}
@ -155,30 +189,54 @@ internal sealed class MossTankPanel
_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.
if (!TryCast(automation, next, $"Buffing {_castThisPass + 1}/{_plan.Count}"))
_planIndex++;
_status = $"Skipped {next.Name} ({gate}).";
return;
}
private bool TryVitalUpkeep(IAutomationSurface automation)
{
VitalAction action = VitalPlan.Decide(automation.Character, _vitalSettings);
if (action == VitalAction.None)
return false;
string stem = action == VitalAction.StaminaToMana
? VitalPlan.StaminaToManaStem
: VitalPlan.RevitalizeStem;
if (!VitalPlan.TryFind(
automation.Spells.KnownSelfBuffs, stem, SkillLevels(automation),
_buffSettings.SkillExcessOverDifficulty, out PluginSpellInfo spell))
{
// Not knowing the conversion is not an error — plenty of characters
// do not have it. Fall through to buffing rather than stalling.
return false;
}
if (automation.Magic.Cast(next.SpellId))
return TryCast(automation, spell, action.ToString());
}
private bool TryCast(
IAutomationSurface automation, PluginSpellInfo spell, string label)
{
PluginCastGate gate = automation.Magic.EvaluateGate(spell.SpellId);
if (gate != PluginCastGate.Ready)
{
_castThisPass++;
_sinceLastCast = 0;
_sinceProgress = 0;
_planIndex = 0;
_status = $"Casting {next.Name} ({_castThisPass} cast)…";
_host.Log.Info($"MossTank: casting {next.Name} (0x{next.SpellId:X4})");
_status = $"{spell.Name}: {gate}";
return false;
}
else
if (!automation.Magic.Cast(spell.SpellId))
{
_planIndex++;
_status = $"Refused {next.Name}.";
_status = $"Refused {spell.Name}.";
return false;
}
_castThisPass++;
_sinceLastCast = 0;
_sinceProgress = 0;
_planIndex = 0;
_status = $"{label}: {spell.Name}";
_host.Log.Info($"MossTank: casting {spell.Name} (0x{spell.SpellId:X4})");
return true;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
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 record VitalSettings
{
/// <summary>Convert stamina to mana below this fraction of max mana.</summary>
public double ManaFloor { get; init; } = 0.50;
/// <summary>Stop converting once mana is back above this fraction.</summary>
public double ManaTarget { get; init; } = 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; init; } = 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)
{
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;
}

View file

@ -1,8 +1,10 @@
<?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}" />
{Buff} is an Action bound to the button, {IsInWorld} hides the whole panel
at character select, the rest are read every frame. -->
<panel x="40" y="120" w="360" h="132" title="MossTank" visible="{IsInWorld}">
<label x="12" y="30" text="{Vitals}" color="#FFB9C7A0" />
<label x="12" y="50" text="{Coverage}" color="#FF8F9C78" />
<label x="12" y="70" text="{Status}" color="#FFE8E4C8" />
<button x="12" y="94" w="108" h="28" text="{ButtonText}" onclick="{Buff}" />
</panel>

View file

@ -4,147 +4,210 @@ 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.
/// The buff policy decides what gets cast, so it is the part worth pinning. It
/// is a pure function of (buff lines, character state) precisely so these tests
/// need no host and no session.
/// </summary>
public class BuffPlanTests
{
private const double Refresh = 60.0;
private const uint LifeMagicSkill = 33u;
private const uint CreatureEnchantmentSkill = 31u;
private static readonly BuffSettings Default = new();
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);
uint id, uint family, int tier, string description,
int difficulty = 50, int mana = 10, uint school = CreatureEnchantmentSkill,
float duration = 1800f) =>
new(id, $"spell-{id}", family, tier, difficulty, mana, duration, school,
description, IsSelfTargeted: true, IsBeneficial: true);
private static PluginActiveEnchantment Active(
uint id, uint family, int tier, double seconds) =>
new(id, family, tier, seconds);
private static PluginSkillInfo Skill(
uint id, string name, PluginSkillTraining training, uint level = 300) =>
new(id, name, training, level);
private static PluginAttributeInfo Attribute(int kind, string name) =>
new(kind, name, 200);
private static List<BuffLine> Lines(params PluginSpellInfo[] spells) =>
BuffProfile.Build(spells);
// ── BuffProfile: deriving the target from retail's own description ────
[Theory]
[InlineData("Increases the caster's Life Magic skill by 10 points.",
BuffTargetKind.Skill, "Life Magic")]
[InlineData("Increases the caster's Strength by 10 points.",
BuffTargetKind.Attribute, "Strength")]
// The trap that would break any name-matching scheme: the spell line called
// Willpower raises the attribute named Self.
[InlineData("Increases the caster's Self by 10 points.",
BuffTargetKind.Attribute, "Self")]
// Retail's spell text and skill table disagree on this skill's name.
[InlineData("Increases the caster's Assess Monster skill by 10 points.",
BuffTargetKind.Skill, "Assess Creature")]
public void ParsesBuffTargetFromDescription(
string description, BuffTargetKind expectedKind, string expectedTarget)
{
Assert.True(BuffProfile.TryParseTarget(description, out var kind, out string target));
Assert.Equal(expectedKind, kind);
Assert.Equal(expectedTarget, target);
}
[Fact]
public void WithNothingActive_CastsTheStrongestTierPerFamily()
public void IgnoresSpellsWhoseDescriptionSaysNothingAboutAStat()
{
var known = new[]
{
Spell(1, family: 10, tier: 1),
Spell(2, family: 10, tier: 7),
Spell(3, family: 20, tier: 4),
};
Assert.False(BuffProfile.TryParseTarget(
"Drains one-half of the caster's Stamina and gives 90% of that to his/her Mana.",
out _, out _));
}
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
[Fact]
public void ExcludesInstantaneousSpellsFromBuffLines()
{
// Vital transfers have no duration and share families across unrelated
// lines, so treating them as buffs would be wrong twice over.
var instant = Spell(1, family: 89, tier: 1,
"Increases the caster's Strength by 10 points.", duration: 0f);
Assert.Empty(BuffProfile.Build(new[] { instant }));
}
// ── BuffPlan: what actually gets cast ────────────────────────────────
[Fact]
public void BuffsTrainedSkillsAndSkipsUntrainedOnes()
{
var lines = Lines(
Spell(1, 47, 1, "Increases the caster's Life Magic skill by 10 points."),
Spell(2, 71, 1, "Increases the caster's Leadership skill by 10 points."));
var plan = BuffPlan.Build(
lines,
new[]
{
Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Specialized),
Skill(35, "Leadership", PluginSkillTraining.Untrained),
},
Array.Empty<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Default);
Assert.Single(plan);
Assert.Equal(1u, plan[0].SpellId);
}
[Fact]
public void BuffsEveryAttribute()
{
var lines = Lines(
Spell(1, 1, 1, "Increases the caster's Strength by 10 points."),
Spell(2, 11, 1, "Increases the caster's Self by 10 points."));
var plan = BuffPlan.Build(
lines,
Array.Empty<PluginSkillInfo>(),
new[] { Attribute(0, "Strength"), Attribute(5, "Self") },
Array.Empty<PluginActiveEnchantment>(),
Default);
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()
public void PicksTheStrongestTierTheCastingSkillCanCarry()
{
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(2, family: 10, tier: 7, seconds: 900) };
// Skill 300; tiers at difficulty 400 and 200. With the default excess
// of 10, only the 200 tier is reliable.
var lines = Lines(
Spell(1, 47, 7, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 400, school: LifeMagicSkill),
Spell(2, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 200, school: LifeMagicSkill));
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);
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained, 300) },
Array.Empty<PluginAttributeInfo>(),
Array.Empty<PluginActiveEnchantment>(),
Default);
Assert.Single(plan);
Assert.Equal(2u, plan[0].SpellId);
}
[Fact]
public void RefreshesABuffThatIsAboutToExpire()
public void SkipsAFamilyAlreadyInForceAtAnEqualTierWithTimeLeft()
{
var known = new[] { Spell(2, family: 10, tier: 7) };
var active = new[] { Active(2, family: 10, tier: 7, seconds: 5) };
var lines = Lines(
Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var plan = BuffPlan.Build(known, active, Refresh);
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) },
Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 4, 900) },
Default);
Assert.Single(plan);
Assert.Equal(2u, plan[0].SpellId);
Assert.Empty(plan);
}
[Fact]
public void TreatsFamilyZeroSpellsIndividually()
public void RecastsWhenTheBuffInForceIsWeaker()
{
// 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 lines = Lines(
Spell(2, 47, 7, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
var plan = BuffPlan.Build(
lines,
new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) },
Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 2, 900) },
Default);
Assert.Equal(3, plan.Count);
Assert.Single(plan);
}
[Fact]
public void SkipsAnIndividuallyActiveFamilyZeroSpell()
public void RefreshesBelowVirindiTanksFiveMinuteThreshold()
{
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 lines = Lines(
Spell(1, 47, 4, "Increases the caster's Life Magic skill by 10 points.",
difficulty: 100, school: LifeMagicSkill));
var skills = new[] { Skill(LifeMagicSkill, "Life Magic", PluginSkillTraining.Trained) };
var plan = BuffPlan.Build(known, active, Refresh);
var comfortable = BuffPlan.Build(lines, skills, Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 4, 301) }, Default);
var expiring = BuffPlan.Build(lines, skills, Array.Empty<PluginAttributeInfo>(),
new[] { new PluginActiveEnchantment(1, 47, 4, 299) }, Default);
Assert.Single(plan);
Assert.Equal(102u, plan[0].SpellId);
Assert.Empty(comfortable);
Assert.Single(expiring);
}
[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 lines = Lines(
Spell(1, 1, 1, "Increases the caster's Strength by 10 points.", mana: 500),
Spell(2, 3, 1, "Increases the caster's Endurance by 10 points.", mana: 5),
Spell(3, 5, 1, "Increases the caster's Quickness by 10 points.", mana: 50));
var plan = BuffPlan.Build(known, Array.Empty<PluginActiveEnchantment>(), Refresh);
var plan = BuffPlan.Build(
lines, Array.Empty<PluginSkillInfo>(),
new[] { Attribute(0, "Strength"), Attribute(1, "Endurance"), Attribute(2, "Quickness") },
Array.Empty<PluginActiveEnchantment>(), Default);
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));
Array.Empty<BuffLine>(), Array.Empty<PluginSkillInfo>(),
Array.Empty<PluginAttributeInfo>(), Array.Empty<PluginActiveEnchantment>(),
Default));
}
}

View file

@ -0,0 +1,112 @@
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank;
namespace AcDream.Plugins.MossTank.Tests;
/// <summary>
/// The mana-upkeep loop: convert stamina to mana when mana is low, restore
/// stamina with Revitalize when that leaves stamina too low to convert.
/// </summary>
public class VitalPlanTests
{
private static readonly VitalSettings Default = new();
private sealed class Character : ICharacterInfo
{
public bool IsInWorld => true;
public uint CurrentHealth { get; init; }
public uint MaxHealth { get; init; } = 100;
public uint CurrentStamina { get; init; }
public uint MaxStamina { get; init; } = 100;
public uint CurrentMana { get; init; }
public uint MaxMana { get; init; } = 100;
public IReadOnlyList<PluginSkillInfo> Skills { get; init; } = [];
public IReadOnlyList<PluginAttributeInfo> Attributes { get; init; } = [];
public IReadOnlyList<PluginActiveEnchantment> ActiveEnchantments { get; init; } = [];
public bool TryGetSkill(uint skillId, out PluginSkillInfo skill)
{
skill = default;
return false;
}
}
private static PluginSpellInfo Spell(
uint id, string name, int tier, int difficulty = 50) =>
new(id, name, Family: 89, tier, difficulty, ManaCost: 0, DurationSeconds: 0f,
School: 33u, Description: string.Empty,
IsSelfTargeted: true, IsBeneficial: true);
[Fact]
public void ConvertsStaminaToManaWhenManaIsLowAndStaminaIsAvailable()
{
var character = new Character { CurrentMana = 10, CurrentStamina = 90 };
Assert.Equal(VitalAction.StaminaToMana, VitalPlan.Decide(character, Default));
}
[Fact]
public void RevitalizesWhenBothManaAndStaminaAreLow()
{
// Converting here would drain what little stamina is left, so stamina
// has to come back first.
var character = new Character { CurrentMana = 10, CurrentStamina = 10 };
Assert.Equal(VitalAction.Revitalize, VitalPlan.Decide(character, Default));
}
[Fact]
public void DoesNothingWhenManaIsHealthy()
{
var character = new Character { CurrentMana = 95, CurrentStamina = 20 };
Assert.Equal(VitalAction.None, VitalPlan.Decide(character, Default));
}
[Fact]
public void DoesNothingWhenVitalsAreUnknown()
{
// Unpublished vitals read as zero; treating that as "empty" would cast
// on a character that is perfectly fine.
var character = new Character { MaxMana = 0, MaxStamina = 0 };
Assert.Equal(VitalAction.None, VitalPlan.Decide(character, Default));
}
[Fact]
public void PicksTheStrongestCastableConversionByName()
{
// Family cannot identify these: retail puts Stamina to Health and
// Stamina to Mana in the SAME family, so only the name distinguishes
// them and a family-based pick would convert into the wrong vital.
var known = new[]
{
Spell(1, "Stamina to Mana Self I", tier: 1),
Spell(2, "Stamina to Mana Self VI", tier: 6),
Spell(3, "Stamina to Health Self VII", tier: 7),
};
var levels = new Dictionary<uint, uint> { [33u] = 300 };
Assert.True(VitalPlan.TryFind(
known, VitalPlan.StaminaToManaStem, levels, 10, out PluginSpellInfo pick));
Assert.Equal(2u, pick.SpellId);
}
[Fact]
public void SkipsConversionTiersTheSkillCannotCarry()
{
var known = new[]
{
Spell(1, "Stamina to Mana Self I", tier: 1, difficulty: 50),
Spell(2, "Stamina to Mana Self VII", tier: 7, difficulty: 400),
};
var levels = new Dictionary<uint, uint> { [33u] = 100 };
Assert.True(VitalPlan.TryFind(
known, VitalPlan.StaminaToManaStem, levels, 10, out PluginSpellInfo pick));
Assert.Equal(1u, pick.SpellId);
}
[Fact]
public void ReportsNoConversionWhenTheCharacterKnowsNone()
{
var known = new[] { Spell(1, "Strength Self I", tier: 1) };
Assert.False(VitalPlan.TryFind(
known, VitalPlan.StaminaToManaStem, new Dictionary<uint, uint>(), 10, out _));
}
}

View file

@ -0,0 +1,77 @@
// Emit a C# enum of every spell in portal.dat's SpellTable.
//
// Generated rather than hand-written or copied from another project: the ids
// and names come from the user's own game data, so the enum cannot drift from
// what the client actually loads, and regenerating is a one-liner.
using System.Text;
using AcDream.Core.Spells;
namespace SpellDump;
internal static class EmitEnum
{
public static void Run(SpellTable table, string outPath, string ns, string typeName)
{
var byName = new Dictionary<string, uint>(StringComparer.Ordinal);
var ordered = new List<(uint Id, string Ident, string Name)>();
foreach (uint id in table.SpellIds.OrderBy(i => i))
{
if (!table.TryGet(id, out SpellMetadata meta))
continue;
string ident = Identifier(meta.Name);
if (ident.Length == 0)
ident = "Spell";
// Retail reuses display names across ids; suffix collisions with the
// id so every member stays addressable and stable.
if (byName.ContainsKey(ident))
ident = $"{ident}_{id:X4}";
byName[ident] = id;
ordered.Add((id, ident, meta.Name));
}
var sb = new StringBuilder();
sb.AppendLine("// <auto-generated>");
sb.AppendLine("// Generated by tools/SpellDump from portal.dat's SpellTable (0x0E00000E).");
sb.AppendLine("// Do not edit by hand. Regenerate with:");
sb.AppendLine("// dotnet run --project tools/SpellDump -- --enum");
sb.AppendLine("// </auto-generated>");
sb.AppendLine();
sb.AppendLine($"namespace {ns};");
sb.AppendLine();
sb.AppendLine("/// <summary>Every spell id in the retail spell table, by name.</summary>");
sb.AppendLine($"public enum {typeName} : uint");
sb.AppendLine("{");
foreach (var (id, ident, name) in ordered)
{
sb.AppendLine($" /// <summary>{System.Security.SecurityElement.Escape(name)}</summary>");
sb.AppendLine($" {ident} = 0x{id:X4},");
}
sb.AppendLine("}");
File.WriteAllText(outPath, sb.ToString());
Console.WriteLine($"wrote {outPath}: {ordered.Count} spells");
}
private static string Identifier(string name)
{
var sb = new StringBuilder(name.Length);
bool upper = true;
foreach (char c in name)
{
if (char.IsLetterOrDigit(c))
{
sb.Append(upper ? char.ToUpperInvariant(c) : c);
upper = false;
}
else
{
upper = true; // word break -> PascalCase
}
}
string ident = sb.ToString();
if (ident.Length > 0 && char.IsDigit(ident[0]))
ident = "S" + ident;
return ident;
}
}

View file

@ -0,0 +1,67 @@
// Dump self-targeted beneficial spells from portal.dat's SpellTable so buff
// selection can be built on what the data actually says rather than on
// remembered spell names.
using AcDream.Content;
using AcDream.Core.Spells;
using DatReaderWriter;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
MagicCatalog catalog = MagicCatalog.Load(adapter);
SpellTable table = catalog.SpellTable;
Console.WriteLine($"spells loaded: {table.Count}");
if (args.Length > 0 && args[0] == "--desc")
{
foreach (uint id in table.SpellIds.OrderBy(i => i))
{
if (!table.TryGet(id, out var m)) continue;
if (!m.IsSelfTargeted || !m.IsBeneficial || m.IsDebuff) continue;
if (m.Generation != 1) continue; // one representative per line
Console.WriteLine($"fam {m.Family,-5} 0x{m.SpellId:X4} {m.Name,-40} | {m.Description}");
}
return;
}
if (args.Length > 0 && args[0] == "--skills")
{
var skillTable = dats.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u)
?? throw new InvalidOperationException("SkillTable 0x0E000004 missing");
Console.WriteLine($"skills: {skillTable.Skills.Count}");
foreach (var kv in skillTable.Skills.OrderBy(k => (uint)k.Key))
Console.WriteLine($" {(uint)kv.Key,-4} {kv.Value.Name}");
return;
}
if (args.Length > 0 && args[0] == "--enum")
{
string outPath = args.Length > 1
? args[1]
: Path.Combine("src", "AcDream.Plugins.MossTank", "SpellId.g.cs");
SpellDump.EmitEnum.Run(table, outPath, "AcDream.Plugins.MossTank", "SpellId");
return;
}
string filter = args.Length > 0 ? args[0].ToLowerInvariant() : "";
var rows = table.SpellIds
.Select(id => table.TryGet(id, out var m) ? m : null)
.Where(s => s is not null)!
.Select(s => s!)
.Where(s => s.IsSelfTargeted && s.IsBeneficial && !s.IsDebuff)
.Where(s => filter.Length == 0 || s.Name.ToLowerInvariant().Contains(filter))
.OrderBy(s => s.Family).ThenBy(s => s.Generation)
.ToList();
Console.WriteLine($"self-targeted beneficial: {rows.Count}\n");
Console.WriteLine($"{"family",-8} {"gen",-4} {"id",-8} {"school",-12} {"mana",-5} {"dur",-8} name");
foreach (var s in rows)
{
Console.WriteLine(
$"{s.Family,-8} {s.Generation,-4} 0x{s.SpellId:X4} {s.School,-12} {s.ManaCost,-5} {s.Duration,-8:F0} {s.Name}");
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>SpellDump</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Chorizite.DatReaderWriter" />
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\..\src\AcDream.Content\AcDream.Content.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,304 @@
{
"version": 2,
"dependencies": {
"net10.0": {
"Chorizite.DatReaderWriter": {
"type": "Direct",
"requested": "[2.1.7, )",
"resolved": "2.1.7",
"contentHash": "6CpUhfHDV/O+lNx0xUZUcXK7wGkEkHYCnGHFtD8BBeAv4i1BuaNfTID+VQoSzx2EtmOVM0A2O/9wRFibApz6rQ==",
"dependencies": {
"DotNet.Standard.Common": "2.0.1",
"ZLibDotNet": "0.1.1"
}
},
"Autofac": {
"type": "Transitive",
"resolved": "8.4.0",
"contentHash": "XMWHyO6fXTv8rwCfhm6+64mQS6CyL0rve/hWSODsUrVuEGtq1fjxSOlVTBqCRsW6L8K3OQDskJaPB1boVMI2eQ=="
},
"Chorizite.ACProtocol": {
"type": "Transitive",
"resolved": "1.0.1",
"contentHash": "PVDw/KRu4WPxT+2MzHwOQ9UFqYlOgpIRswSnco/EgHSHnbQyxOqxwiOwSK0Il+cI6dTSXTW34QNmL7iH0lXLKw==",
"dependencies": {
"Chorizite.Common": "1.0.0",
"Medo.PcapRW": "1.2.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
"System.CodeDom": "9.0.0"
}
},
"Chorizite.Common": {
"type": "Transitive",
"resolved": "1.0.3",
"contentHash": "KqI0su7UY2diiSQuq11gF/NztqR6orZLr/e5UKTQ91XM8OsZGgBGHmhf2/jopX9VhwpXOTTLIeFBYTBc30cK8w==",
"dependencies": {
"Microsoft.Extensions.Logging.Abstractions": "9.0.0"
}
},
"CommunityToolkit.HighPerformance": {
"type": "Transitive",
"resolved": "8.4.0",
"contentHash": "flxspiBs0G/0GMp7IK2J2ijV9bTG6hEwFc/z6ekHqB6nwRJ4Ry2yLdx+TkbCUYFCl4XhABkAwomeKbT6zM2Zlg=="
},
"Cyotek.Drawing.BitmapFont": {
"type": "Transitive",
"resolved": "2.0.4",
"contentHash": "iA6WehGVdMUuNbfsQQDq/Bt+mMd/OqHjiMUtKFLIQd/0pyYh4ehT7FEjTxN9/4OXNKQZsp9bAJgltP2nnswUJg=="
},
"DotNet.Standard.Common": {
"type": "Transitive",
"resolved": "2.0.1",
"contentHash": "zW0m0ytHi43ccbEOTNDa10cDDnT7BAzY1R1Rb1dlhbdkiyglsALjrsyTPSEjbdnTmCOAvAvl4kkbvBLoYhC6dQ=="
},
"FontStashSharp": {
"type": "Transitive",
"resolved": "1.3.10",
"contentHash": "7JTrihTt3DR8LYbb4L1eZcnbwOUOu/mvY+PJoZ3WVWiKjA6xNUk93GSW3OC/kZBD3iYzrXK8QlmKyYY5Lef/Rg==",
"dependencies": {
"Cyotek.Drawing.BitmapFont": "2.0.4",
"FontStashSharp.Base": "1.1.9",
"FontStashSharp.Rasterizers.StbTrueTypeSharp": "1.1.9",
"StbImageSharp": "2.30.15"
}
},
"FontStashSharp.Base": {
"type": "Transitive",
"resolved": "1.1.9",
"contentHash": "/AjkOcPNijs8vyNgcCj3FfBJbVWmsSH744hqkhLfBt8qspDz/tEoD+U09my5u9eRBX6zX+RLQ/gdCvwy+ZBOtg=="
},
"FontStashSharp.Rasterizers.StbTrueTypeSharp": {
"type": "Transitive",
"resolved": "1.1.9",
"contentHash": "yi5iuTERem46uyHC5p+jRi3Jh8dKWzgNWLqcvHciGlyVHD1cWFdERgnxshZU4xWB2hRGnogxaCudCirbMpg4eQ==",
"dependencies": {
"FontStashSharp.Base": "1.1.9",
"StbTrueTypeSharp": "1.26.12"
}
},
"Medo.PcapRW": {
"type": "Transitive",
"resolved": "1.2.0",
"contentHash": "vgwcHDg60Q9LJfry7twA78pUFio1P4EypI2IIlk+7mEuySsIRInm+Gx2OINDICyocbuyQZdS/zABjbmejAObeg=="
},
"Microsoft.Bcl.AsyncInterfaces": {
"type": "Transitive",
"resolved": "1.1.0",
"contentHash": "1Am6l4Vpn3/K32daEqZI+FFr96OlZkgwK2LcT3pZ2zWubR5zTPW3/FkO1Rat9kb7oQOa4rxgl9LJHc5tspCWfg=="
},
"Microsoft.Diagnostics.NETCore.Client": {
"type": "Transitive",
"resolved": "0.2.410101",
"contentHash": "I4hMjlbPcM5R+M4ThD2Zt1z58M8uZnWkDbFLXHntOOAajajEucrw4XYNSaoi5rgoqksgxQ3g388Vof4QzUNwdQ==",
"dependencies": {
"Microsoft.Bcl.AsyncInterfaces": "1.1.0",
"Microsoft.Extensions.Logging": "2.1.1"
}
},
"Microsoft.Diagnostics.Runtime": {
"type": "Transitive",
"resolved": "3.1.512801",
"contentHash": "0lMUDr2oxNZa28D6NH5BuSQEe5T9tZziIkvkD44YkkCGQXPJqvFjLq5ZQq1hYLl3RjQJrY+hR0jFgap+EWPDTw==",
"dependencies": {
"Microsoft.Diagnostics.NETCore.Client": "0.2.410101"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "LjVKO6P2y52c5ZhTLX/w8zc5H4Y3J/LJsgqTBj49TtFq/hAtVNue/WA0F6/7GMY90xhD7K0MDZ4qpOeWXbLvzg==",
"dependencies": {
"Microsoft.Extensions.Configuration.Abstractions": "2.1.1"
}
},
"Microsoft.Extensions.Configuration.Abstractions": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "VfuZJNa0WUshZ/+8BFZAhwFKiKuu/qOUCFntfdLpHj7vcRnsGHqd3G2Hse78DM+pgozczGM63lGPRLmy+uhUOA==",
"dependencies": {
"Microsoft.Extensions.Primitives": "2.1.1"
}
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "fcLCTS03poWE4v9tSNBr3pWn0QwGgAn1vzqHXlXgvqZeOc7LvQNzaWcKRQZTdEc3+YhQKwMsOtm3VKSA2aWQ8w==",
"dependencies": {
"Microsoft.Extensions.Configuration": "2.1.1"
}
},
"Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive",
"resolved": "9.0.9",
"contentHash": "/hymojfWbE9AlDOa0mczR44m00Jj+T3+HZO0ZnVTI032fVycI0ZbNOVFP6kqZMcXiLSYXzR2ilcwaRi6dzeGyA=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "hh+mkOAQDTp6XH80xJt3+wwYVzkbwYQl9XZRCz4Um0JjP/o7N9vHM3rZ6wwwtr+BBe/L6iBO2sz0px6OWBzqZQ==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "2.1.1",
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1",
"Microsoft.Extensions.Logging.Abstractions": "2.1.1",
"Microsoft.Extensions.Options": "2.1.1"
}
},
"Microsoft.Extensions.Options": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "V7lXCU78lAbzaulCGFKojcCyG8RTJicEbiBkPJjFqiqXwndEBBIehdXRMWEVU3UtzQ1yDvphiWUL9th6/4gJ7w==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "2.1.1",
"Microsoft.Extensions.Primitives": "2.1.1"
}
},
"Microsoft.Extensions.Primitives": {
"type": "Transitive",
"resolved": "2.1.1",
"contentHash": "scJ1GZNIxMmjpENh0UZ8XCQ6vzr/LzeF9WvEA51Ix2OQGAs9WPgPu8ABVUdvpKPLuor/t05gm6menJK3PwqOXg=="
},
"Namotion.Reflection": {
"type": "Transitive",
"resolved": "3.4.3",
"contentHash": "KLk2gLR9f8scM82EiL+p9TONXXPy9+IAZVMzJOA/Wsa7soZD7UJGG6j0fq0D9ZoVnBRRnSeEC7kShhRo3Olgaw=="
},
"Newtonsoft.Json": {
"type": "Transitive",
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"NJsonSchema": {
"type": "Transitive",
"resolved": "11.5.1",
"contentHash": "3a7ntoBncSKkLgpIhT3uQ8BiyDzYKOHIzpzNF4o1vtKc+Re4vWxBcDXFDarOWcr/UkxZ8nxRXbbWk05j6bXFzQ==",
"dependencies": {
"NJsonSchema.Annotations": "11.5.1",
"Namotion.Reflection": "3.4.3",
"Newtonsoft.Json": "13.0.3"
}
},
"NJsonSchema.Annotations": {
"type": "Transitive",
"resolved": "11.5.1",
"contentHash": "xiqZ2DBJM1HuV+EhXgueb5ZUBlWFN3kVfLTKdtpTSxvtyQCO/vit8lqZiUiejnReUMRMIUhtS9m0GbieHZlSow=="
},
"SixLabors.Fonts": {
"type": "Transitive",
"resolved": "2.1.3",
"contentHash": "ORWbZ5BHrC/LZvo+Y09MnoJq5VUKD85LsYALk+YI7CHFra+m5arCkz00IntDM6SrAiB22bvSdKtKmuCyHOKlqg=="
},
"SixLabors.ImageSharp.Drawing": {
"type": "Transitive",
"resolved": "2.1.7",
"contentHash": "9KwCo9Fa350cx6ckpsy8NqXQZKwir4RQ8Kj0sdCmJA7wsK9FMyfgC527Sn4l/D6bj2ditSHlhS7dGzcgGszvSQ==",
"dependencies": {
"SixLabors.Fonts": "2.1.3",
"SixLabors.ImageSharp": "3.1.11"
}
},
"System.CodeDom": {
"type": "Transitive",
"resolved": "9.0.0",
"contentHash": "oTE5IfuMoET8yaZP/vdvy9xO47guAv/rOhe4DODuFBN3ySprcQOlXqO3j+e/H/YpKKR5sglrxRaZ2HYOhNJrqA=="
},
"ZLibDotNet": {
"type": "Transitive",
"resolved": "0.1.1",
"contentHash": "QEti4O7dwRcOb9zbnLuudSrt2IT61OYjq0R7lcJb+EzUm5N6djOVGU/cp+FZIZzJUnaFIntwrpwiuRvhpS7ZHg=="
},
"acdream.content": {
"type": "Project",
"dependencies": {
"AcDream.Core": "[1.0.0, )",
"BCnEncoder.Net.ImageSharp": "[1.1.2, )",
"SixLabors.ImageSharp": "[3.1.12, )"
}
},
"acdream.core": {
"type": "Project",
"dependencies": {
"AcDream.Plugin.Abstractions": "[1.0.0, )",
"BCnEncoder.Net": "[2.2.1, )",
"Chorizite.Core": "[0.0.18, )",
"Chorizite.DatReaderWriter": "[2.1.7, )",
"Serilog": "[4.0.2, )",
"StbImageSharp": "[2.30.16, )"
}
},
"acdream.plugin.abstractions": {
"type": "Project"
},
"BCnEncoder.Net": {
"type": "CentralTransitive",
"requested": "[2.2.1, )",
"resolved": "2.2.1",
"contentHash": "tI5+/OQo0kciLqWrViRjpOH+IL3FjexYnoWZajiGV41g/EM9CGbWsxsPzBDmpoxNkrV9uox/EtIhCIi9chBSFw==",
"dependencies": {
"CommunityToolkit.HighPerformance": "8.4.0"
}
},
"BCnEncoder.Net.ImageSharp": {
"type": "CentralTransitive",
"requested": "[1.1.2, )",
"resolved": "1.1.2",
"contentHash": "qUi8L+bNfHJii95BMBcV6MhBchkKU2VV6sd6D1yyzgm77YhMt+aFT0keh5uf70bvTsRrq/ZKQnE4UQScNU6XAA==",
"dependencies": {
"BCnEncoder.Net": "2.2.0",
"CommunityToolkit.HighPerformance": "8.4.0",
"SixLabors.ImageSharp": "3.1.7"
}
},
"Chorizite.Core": {
"type": "CentralTransitive",
"requested": "[0.0.18, )",
"resolved": "0.0.18",
"contentHash": "Pvf5idSsN0NfhZspJhrpo7QiNTmHx3mnKFkmAvHFpEFx+RnqfXh96Q+Pxam+hrqXbimmKGfk1ooIl5kpQcMo6w==",
"dependencies": {
"Autofac": "8.4.0",
"Chorizite.ACProtocol": "1.0.1",
"Chorizite.Common": "1.0.3",
"Chorizite.DatReaderWriter": "1.0.0",
"FontStashSharp": "1.3.10",
"Microsoft.Diagnostics.Runtime": "3.1.512801",
"Microsoft.Extensions.Logging.Abstractions": "9.0.9",
"NJsonSchema": "11.5.1",
"SixLabors.ImageSharp": "3.1.11",
"SixLabors.ImageSharp.Drawing": "2.1.7"
}
},
"Microsoft.Extensions.Logging.Abstractions": {
"type": "CentralTransitive",
"requested": "[9.0.9, )",
"resolved": "9.0.9",
"contentHash": "FEgpSF+Z9StMvrsSViaybOBwR0f0ZZxDm8xV5cSOFiXN/t+ys+rwAlTd/6yG7Ld1gfppgvLcMasZry3GsI9lGA==",
"dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.9"
}
},
"Serilog": {
"type": "CentralTransitive",
"requested": "[4.0.2, )",
"resolved": "4.0.2",
"contentHash": "Vehq4uNYtURe/OnHEpWGvMgrvr5Vou7oZLdn3BuEH5FSCeHXDpNJtpzWoqywXsSvCTuiv0I65mZDRnJSeUvisA=="
},
"SixLabors.ImageSharp": {
"type": "CentralTransitive",
"requested": "[3.1.12, )",
"resolved": "3.1.12",
"contentHash": "iAg6zifihXEFS/t7fiHhZBGAdCp3FavsF4i2ZIDp0JfeYeDVzvmlbY1CNhhIKimaIzrzSi5M/NBFcWvZT2rB/A=="
},
"StbImageSharp": {
"type": "CentralTransitive",
"requested": "[2.30.16, )",
"resolved": "2.30.16",
"contentHash": "qg1i+NHihXVKLKYacGKrauhSQIGL31eBWcTC4Vc7jnGmBFj87LkRuCdXB5aDZisWMRnB6x2mcfwYXQxMWEG/lw=="
},
"StbTrueTypeSharp": {
"type": "CentralTransitive",
"requested": "[1.26.12, )",
"resolved": "1.26.12",
"contentHash": "hCc6/OsfcPa5VsLECcEU2m78WOshBrKwK42nAodSm9Z5wH68f7n66SoiRLCdGCkDaqbWz2TlX4zYHIjogj1HJA=="
}
}
}
}