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:
parent
9d1117b923
commit
17ebfc434d
18 changed files with 14052 additions and 279 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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++)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue