feat(mosstank): add VTank-style automation PoC

This commit is contained in:
Erik 2026-08-27 18:57:21 +02:00
parent f6fe0f2a4f
commit 4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions

View file

@ -22,8 +22,7 @@
<None Update="mosstank.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="mosstank-settings.xml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<EmbeddedResource Include="VtankCraftRecipes.tsv" />
<EmbeddedResource Include="VtankAmmunitionOptions.tsv" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,408 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal enum AttackSpellShape
{
Direct,
Arc,
Streak,
Ring,
Harm,
Drain,
Martyr,
}
internal readonly record struct AttackSpellChoice(
PluginSpellInfo Spell,
AttackSpellShape Shape,
MonsterDamageType DamageType,
bool CastWithoutTarget);
/// <summary>
/// VTank's attack vocabulary projected from the learned retail spell table.
/// Shape and element are derived from stable retail spell names/descriptions;
/// the host remains a policy-free provider of canonical DAT metadata.
/// </summary>
internal sealed class AttackSpellCatalog
{
private const uint TuskerFistsSpellId = 0x0B76u;
private readonly AttackSpellChoice[] _choices;
private AttackSpellCatalog(AttackSpellChoice[] choices) =>
_choices = choices;
public static AttackSpellCatalog Build(
IReadOnlyList<PluginSpellInfo> spells)
{
ArgumentNullException.ThrowIfNull(spells);
var choices = new List<AttackSpellChoice>();
foreach (PluginSpellInfo spell in spells)
{
if (TryClassify(spell, out AttackSpellChoice choice))
choices.Add(choice);
}
return new AttackSpellCatalog([.. choices]);
}
/// <summary>
/// Returns VTank's preferred spell forms in retry order. Cast feasibility
/// stays with the host's exact gate, so a lower known tier can be selected
/// when the character cannot currently cast the strongest one.
/// </summary>
public IReadOnlyList<AttackSpellChoice> Candidates(
MonsterRuleActions actions,
CombatSettings settings,
PluginCombatTarget target,
int nearbyRingTargets,
ICharacterInfo character)
{
ArgumentNullException.ThrowIfNull(actions);
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(character);
MonsterDamageType damageMode = ResolveDamageMode(
actions.DamageType,
character);
bool ringDue = actions.UsesRing
&& nearbyRingTargets >= (actions.UsesPrimaryAttack
? Math.Max(1, settings.MinimumRingTargets)
: 1);
var candidates = new List<AttackSpellChoice>();
foreach (AttackSpellChoice choice in _choices)
{
if (!MatchesDamageMode(choice, damageMode))
continue;
if (choice.Shape == AttackSpellShape.Ring && !ringDue)
continue;
if (choice.Shape != AttackSpellShape.Ring
&& !MatchesPrimaryShape(choice.Shape, actions, settings, target))
{
continue;
}
candidates.Add(choice);
}
candidates.Sort((left, right) => Compare(
left,
right,
actions with { DamageType = damageMode },
settings,
target,
ringDue,
character));
return candidates;
}
private static int Compare(
AttackSpellChoice left,
AttackSpellChoice right,
MonsterRuleActions actions,
CombatSettings settings,
PluginCombatTarget target,
bool ringDue,
ICharacterInfo character)
{
// VTank resolves Auto through GameInfoDB before it chooses the
// bolt/arc/streak form. Element preference therefore outranks spell
// shape and tier; an unavailable preferred element naturally falls
// through to the next candidate in the ordered list.
if (actions.DamageType == MonsterDamageType.Auto)
{
int leftDamage = VtankDamageDatabase.PreferenceIndex(
target,
left.DamageType);
int rightDamage = VtankDamageDatabase.PreferenceIndex(
target,
right.DamageType);
int damage = leftDamage.CompareTo(rightDamage);
if (damage != 0)
return damage;
}
int leftPreference = Preference(
left.Shape, actions, settings, target, ringDue, character);
int rightPreference = Preference(
right.Shape, actions, settings, target, ringDue, character);
int preferred = leftPreference.CompareTo(rightPreference);
if (preferred != 0)
return preferred;
int tier = right.Spell.Tier.CompareTo(left.Spell.Tier);
if (tier != 0)
return tier;
int difficulty = right.Spell.Difficulty.CompareTo(left.Spell.Difficulty);
return difficulty != 0
? difficulty
: left.Spell.SpellId.CompareTo(right.Spell.SpellId);
}
private static int Preference(
AttackSpellShape shape,
MonsterRuleActions actions,
CombatSettings settings,
PluginCombatTarget target,
bool ringDue,
ICharacterInfo character)
{
if (ringDue && shape == AttackSpellShape.Ring)
return 0;
if (actions.DamageType == MonsterDamageType.DrainAuto)
{
bool needsHealth = character.MaxHealth != 0u
&& character.CurrentHealth / (double)character.MaxHealth < 0.75d;
if (needsHealth && shape == AttackSpellShape.Drain)
return 1;
if (!needsHealth
&& character.MaxHealth != 0u
&& character.CurrentHealth / (double)character.MaxHealth >= 0.5d
&& shape == AttackSpellShape.Martyr)
{
return 1;
}
return shape switch
{
AttackSpellShape.Drain => 2,
AttackSpellShape.Martyr => 3,
AttackSpellShape.Harm => 4,
_ => 20,
};
}
if (actions.UsesStreak)
{
if (shape == AttackSpellShape.Streak)
return 1;
if (settings.UseArcs && target.Distance >= settings.ArcRange)
return shape == AttackSpellShape.Arc ? 2 : 3;
return shape == AttackSpellShape.Direct ? 2 : 3;
}
if (settings.UseArcs && target.Distance >= settings.ArcRange)
{
if (shape == AttackSpellShape.Arc)
return 1;
if (shape == AttackSpellShape.Direct)
return 2;
}
else
{
if (shape == AttackSpellShape.Direct)
return 1;
if (shape == AttackSpellShape.Arc)
return 2;
}
return shape switch
{
AttackSpellShape.Harm => 1,
AttackSpellShape.Streak => 3,
AttackSpellShape.Arc => 4,
AttackSpellShape.Direct => 5,
_ => 10,
};
}
private static bool MatchesPrimaryShape(
AttackSpellShape shape,
MonsterRuleActions actions,
CombatSettings settings,
PluginCombatTarget target)
{
if (actions.DamageType == MonsterDamageType.DrainAuto)
{
return shape is AttackSpellShape.Drain
or AttackSpellShape.Martyr
or AttackSpellShape.Harm;
}
if (actions.DamageType == MonsterDamageType.Harm)
return shape == AttackSpellShape.Harm;
if (actions.UsesStreak)
{
// Streak is preferred, not a hard requirement: VTank falls back
// when the matching streak/tier is unknown or presently gated.
return shape is AttackSpellShape.Streak
or AttackSpellShape.Direct
or AttackSpellShape.Arc;
}
return shape is AttackSpellShape.Direct or AttackSpellShape.Arc;
}
private static bool MatchesDamageMode(
AttackSpellChoice choice,
MonsterDamageType requested)
{
return requested switch
{
MonsterDamageType.Harm => choice.Shape == AttackSpellShape.Harm,
MonsterDamageType.DrainAuto => choice.Shape is AttackSpellShape.Drain
or AttackSpellShape.Martyr
or AttackSpellShape.Harm,
MonsterDamageType.VoidBasic or MonsterDamageType.Nether =>
choice.DamageType == MonsterDamageType.Nether,
MonsterDamageType.Auto => choice.DamageType is not MonsterDamageType.Auto
&& choice.Shape is not (AttackSpellShape.Harm
or AttackSpellShape.Drain
or AttackSpellShape.Martyr),
_ => choice.DamageType == requested,
};
}
private static MonsterDamageType ResolveDamageMode(
MonsterDamageType requested,
ICharacterInfo character)
{
// VTank's ga/hi pair treats Prismatic as an ammunition policy while
// retaining normal GameInfoDB element selection for magic. Fists is
// special only while the Tusker Fists enchantment is active;
// otherwise ga resolves the attack element to Bludgeon.
if (requested == MonsterDamageType.Prismatic)
return MonsterDamageType.Auto;
if (requested == MonsterDamageType.Fists)
{
return character.ActiveEnchantments.Any(
static enchantment => enchantment.SpellId == TuskerFistsSpellId)
? MonsterDamageType.Fists
: MonsterDamageType.Bludgeon;
}
if (requested != MonsterDamageType.Auto)
return requested;
bool hasWar = IsTrained(character, 34u);
if (hasWar)
return MonsterDamageType.Auto;
if (IsTrained(character, 43u))
return MonsterDamageType.VoidBasic;
return IsTrained(character, 33u)
? MonsterDamageType.DrainAuto
: MonsterDamageType.Auto;
}
private static bool IsTrained(ICharacterInfo character, uint skillId) =>
character.TryGetSkill(skillId, out PluginSkillInfo skill)
&& skill.Training is PluginSkillTraining.Trained
or PluginSkillTraining.Specialized;
internal static bool TryClassify(
PluginSpellInfo spell,
out AttackSpellChoice choice)
{
string name = Normalize(spell.Name);
AttackSpellShape shape;
MonsterDamageType damage;
if (spell.SpellId == TuskerFistsSpellId
|| name.Equals("Tusker Fists", StringComparison.OrdinalIgnoreCase))
{
shape = AttackSpellShape.Direct;
damage = MonsterDamageType.Fists;
}
else if (name.StartsWith("Harm Other", StringComparison.OrdinalIgnoreCase))
{
shape = AttackSpellShape.Harm;
damage = MonsterDamageType.Harm;
}
else if (name.StartsWith(
"Drain Health Other", StringComparison.OrdinalIgnoreCase))
{
shape = AttackSpellShape.Drain;
damage = MonsterDamageType.DrainAuto;
}
else if (name.StartsWith(
"Martyr's Hecatomb", StringComparison.OrdinalIgnoreCase))
{
shape = AttackSpellShape.Martyr;
damage = MonsterDamageType.DrainAuto;
}
else
{
if (!spell.IsOffensive
|| spell.IsBeneficial
|| spell.IsDebuff
|| spell.IsDamageOverTime
|| DebuffSpellCatalog.TryClassify(spell, out _, out _))
{
choice = default;
return false;
}
damage = DamageFromText(spell.Description, name);
if (damage == MonsterDamageType.Auto)
{
choice = default;
return false;
}
if (name.Contains(" Streak", StringComparison.OrdinalIgnoreCase))
shape = AttackSpellShape.Streak;
else if (name.Contains(" Arc", StringComparison.OrdinalIgnoreCase))
shape = AttackSpellShape.Arc;
else if ((spell.TargetMask == 0u || spell.IsUntargeted)
&& (name.Contains(" Ring", StringComparison.OrdinalIgnoreCase)
|| spell.Description.Contains(
"outward from the caster",
StringComparison.OrdinalIgnoreCase)))
{
shape = AttackSpellShape.Ring;
}
else if (spell.TargetMask != 0u || spell.IsProjectile)
shape = AttackSpellShape.Direct;
else
{
choice = default;
return false;
}
}
choice = new AttackSpellChoice(
spell,
shape,
damage,
shape == AttackSpellShape.Ring
|| spell.IsUntargeted
|| spell.TargetMask == 0u);
return true;
}
private static MonsterDamageType DamageFromText(
string description,
string name)
{
string text = string.Concat(description, " ", name);
if (text.Contains("slashing damage", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Blade", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Slash;
if (text.Contains("piercing damage", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Pierce;
if (text.Contains("bludgeoning damage", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Shock Wave", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Bludgeon;
if (text.Contains("cold damage", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Frost", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Cold;
if (text.Contains("fire damage", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Flame", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Fire;
if (text.Contains("acid damage", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Acid", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Acid;
if (text.Contains("electric", StringComparison.OrdinalIgnoreCase)
|| text.Contains("Lightning", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Electric;
if (text.Contains("nether", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Nether;
// The first six tiers call the piercing line Force Bolt; description
// is authoritative, while this name fallback covers sparse fixtures.
if (text.Contains("Force", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Pierce;
return MonsterDamageType.Auto;
}
private static string Normalize(string name)
{
const string incantation = "Incantation of ";
return name.StartsWith(incantation, StringComparison.OrdinalIgnoreCase)
? name[incantation.Length..]
: name;
}
}

View file

@ -0,0 +1,173 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Verbatim decision tree from official VTank <c>hi.cs</c> immediately before
/// its call to <c>bo.a(target,power,spell)</c>. This odd-looking table is
/// intentional: slash/pierce hybrid weapons use different charge points for
/// single, triple-strike, dual-wield and shield arrangements.
/// </summary>
internal static class AutoAttackPower
{
private const uint MeleeWeapon = 0x00000001u;
private const uint MissileWeapon = 0x00000100u;
private const uint ShieldLocation = 0x00200000u;
private const int SlashDamage = 0x0001;
private const int PierceDamage = 0x0002;
private const int TripleSlashAttack = 0x0040;
private const uint RecklessnessSkill = 50u;
public static float Resolve(
MonsterRuleActions actions,
CombatSettings settings,
ICharacterInfo character,
IReadOnlyList<PluginInventoryItem> inventory)
{
ArgumentNullException.ThrowIfNull(actions);
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(inventory);
if (!settings.AutoAttackPower)
return settings.AttackPower;
PluginInventoryItem? weapon = FindWeapon(actions, inventory);
if (weapon is not { } selected)
return settings.AttackPower;
if ((selected.ItemType & MissileWeapon) != 0u)
return ClampForRecklessness(1f, settings, character);
if ((selected.ItemType & MeleeWeapon) == 0u)
return settings.AttackPower;
int requestedDamage = RawDamage(actions.DamageType);
if (requestedDamage is not (SlashDamage or PierceDamage))
return ClampForRecklessness(1f, settings, character);
PluginInventoryItem? offhand = FindOffhand(actions, selected, inventory);
bool offhandMelee = offhand is { } held
&& (held.ItemType & MeleeWeapon) != 0u;
bool offhandShield = offhand is { } shield
&& (shield.EquippedLocation & ShieldLocation) != 0u;
bool slashPierce = (selected.DamageType & (SlashDamage | PierceDamage))
== (SlashDamage | PierceDamage);
bool tripleSlash = (selected.AttackType & TripleSlashAttack) != 0;
float power;
if (selected.WeaponType == 1 && !offhandMelee)
{
power = requestedDamage == SlashDamage && slashPierce ? 0.5f : 0f;
}
else if (requestedDamage == PierceDamage && slashPierce && !tripleSlash)
{
power = 0.2f;
}
else if (requestedDamage == PierceDamage
&& slashPierce
&& tripleSlash
&& offhandMelee)
{
power = 0.49f;
}
else if (requestedDamage != PierceDamage
|| !slashPierce
|| !tripleSlash
|| offhandShield)
{
power = 1f;
}
else
{
power = 0.2f;
}
return ClampForRecklessness(power, settings, character);
}
private static PluginInventoryItem? FindWeapon(
MonsterRuleActions actions,
IReadOnlyList<PluginInventoryItem> inventory)
{
PluginInventoryItem? equipped = null;
PluginInventoryItem? named = null;
foreach (PluginInventoryItem item in inventory)
{
if (actions.WeaponObjectId != 0u
&& item.ObjectId == actions.WeaponObjectId)
{
return item;
}
if (!string.IsNullOrWhiteSpace(actions.WeaponName)
&& item.Name.Equals(actions.WeaponName, StringComparison.Ordinal))
{
named ??= item;
}
if (item.IsEquipped
&& (item.ItemType & (MeleeWeapon | MissileWeapon)) != 0u)
{
equipped ??= item;
}
}
return named ?? equipped;
}
private static PluginInventoryItem? FindOffhand(
MonsterRuleActions actions,
PluginInventoryItem weapon,
IReadOnlyList<PluginInventoryItem> inventory)
{
PluginInventoryItem? equipped = null;
PluginInventoryItem? named = null;
foreach (PluginInventoryItem item in inventory)
{
if (item.ObjectId == weapon.ObjectId)
continue;
if (actions.OffhandObjectId != 0u
&& item.ObjectId == actions.OffhandObjectId)
{
return item;
}
if (!string.IsNullOrWhiteSpace(actions.OffhandName)
&& item.Name.Equals(actions.OffhandName, StringComparison.Ordinal))
{
named ??= item;
}
if (item.IsEquipped
&& ((item.ItemType & MeleeWeapon) != 0u
|| (item.EquippedLocation & ShieldLocation) != 0u))
{
equipped ??= item;
}
}
return named ?? equipped;
}
private static float ClampForRecklessness(
float power,
CombatSettings settings,
ICharacterInfo character)
{
if (!settings.UseRecklessness
|| !character.TryGetSkill(
RecklessnessSkill,
out PluginSkillInfo recklessness)
|| recklessness.Training is not (
PluginSkillTraining.Trained or PluginSkillTraining.Specialized))
{
return power;
}
return Math.Clamp(power, 0.11f, 0.9f);
}
private static int RawDamage(MonsterDamageType damage) => damage switch
{
MonsterDamageType.Slash => SlashDamage,
MonsterDamageType.Pierce => PierceDamage,
MonsterDamageType.Bludgeon => 0x0004,
MonsterDamageType.Cold => 0x0008,
MonsterDamageType.Fire => 0x0010,
MonsterDamageType.Acid => 0x0020,
MonsterDamageType.Electric => 0x0040,
MonsterDamageType.Nether => 0x0400,
_ => 0,
};
}

View file

@ -5,18 +5,32 @@ namespace AcDream.Plugins.MossTank;
/// <summary>Settings that shape a buff pass. Defaults follow Virindi Tank's.</summary>
public sealed class BuffSettings
{
public bool Enabled { get; set; } = true;
/// <summary>
/// VTank's separate idle top-off rule. The ordinary rebuff rule always
/// uses <see cref="RebuffWhenUnderSeconds"/>; this wider window is only
/// considered after combat, loot, and navigation have found no work.
/// </summary>
public bool IdleBuffTopoff { get; set; }
public double IdleBuffTopoffSeconds { get; set; } = 1200.0;
/// <summary>
/// VTank recasts buffs once they drop below five minutes remaining
/// ("all buff spells are recast when they go below 5 minutes").
/// </summary>
public double RebuffWhenUnderSeconds { get; set; } = 300.0;
public double BuffCastRecastSeconds { get; set; } = 30d;
public double BuffCastRecastResetSeconds { get; set; } = 30d;
public bool FastCastBuffs { get; set; }
public bool RandomHelperBuffs { get; set; }
public double RandomHelperIntervalSeconds { get; set; } = 5d;
public string BlacklistedSpellComponents { get; set; } = string.Empty;
/// <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; set; } = 10;
public int SkillExcessOverDifficulty { get; set; } = 5;
/// <summary>Buff every attribute (VTank's default).</summary>
public bool BuffAttributes { get; set; } = true;
@ -26,6 +40,8 @@ public sealed class BuffSettings
/// their own profile (<c>BuffProfile_Prots</c>) and casts them by default.
/// </summary>
public bool BuffProtections { get; set; } = true;
public string ProtectionElements { get; set; } = "ALFCBPS";
public int ProtectionProfileMode { get; set; } = 2;
/// <summary>
/// Self-cast weapon and caster auras — Blood Drinker, Heart Seeker, Swift
@ -38,6 +54,8 @@ public sealed class BuffSettings
/// in their own profile (<c>BuffProfile_Banes</c>) and casts them by default.
/// </summary>
public bool BuffBanes { get; set; } = true;
public string BaneElements { get; set; } = "ALFCBPS";
public int BaneProfileMode { get; set; } = 2;
/// <summary>
/// The vital regeneration rates — Regeneration (health), Rejuvenation
@ -57,6 +75,15 @@ public sealed class BuffSettings
/// "automatically buffs every Attribute and Skill you have trained".
/// </summary>
public bool BuffTrainedSkillsOnly { get; set; } = true;
/// <summary>
/// Minimum current skill at which VTank permits buffing an untrained
/// magic school. These are independent because the three schools can be
/// raised and trained independently.
/// </summary>
public int BuffWithUntrainedItemSkill { get; set; } = 80;
public int BuffWithUntrainedCreatureSkill { get; set; } = 80;
public int BuffWithUntrainedLifeSkill { get; set; } = 80;
}
/// <summary>
@ -80,8 +107,12 @@ public static class BuffPlan
IReadOnlyList<PluginAttributeInfo> attributes,
IReadOnlyList<PluginActiveEnchantment> active,
BuffSettings settings,
bool force = false)
bool force = false,
double? rebuffWhenUnderSeconds = null,
int characterLevel = 0)
{
if (!settings.Enabled && !force)
return [];
var trainedSkills = new Dictionary<string, PluginSkillInfo>(
StringComparer.OrdinalIgnoreCase);
foreach (PluginSkillInfo skill in skills)
@ -120,16 +151,29 @@ public static class BuffPlan
foreach (BuffLine line in lines)
{
uint school = line.Tiers.Count == 0 ? 0u : line.Tiers[0].School;
bool schoolAvailable = IsSchoolAvailable(
school,
skills,
settings,
characterLevel);
bool wanted = line.Kind switch
{
BuffTargetKind.Attribute =>
settings.BuffAttributes && attributeNames.Contains(line.TargetName),
BuffTargetKind.Skill => trainedSkills.ContainsKey(line.TargetName),
BuffTargetKind.Protection => settings.BuffProtections,
BuffTargetKind.Aura => settings.BuffAuras,
BuffTargetKind.Bane => settings.BuffBanes,
BuffTargetKind.Regeneration => settings.BuffRegeneration,
BuffTargetKind.Other => settings.BuffOther,
schoolAvailable && settings.BuffAttributes
&& attributeNames.Contains(line.TargetName),
BuffTargetKind.Skill => schoolAvailable
&& (trainedSkills.ContainsKey(line.TargetName)
|| IsMagicSchoolName(line.TargetName)),
BuffTargetKind.Protection =>
schoolAvailable && settings.BuffProtections
&& ProfileAllows(line, settings, bane: false),
BuffTargetKind.Aura => schoolAvailable && settings.BuffAuras,
BuffTargetKind.Bane => schoolAvailable && settings.BuffBanes
&& ProfileAllows(line, settings, bane: true),
BuffTargetKind.Regeneration =>
schoolAvailable && settings.BuffRegeneration,
BuffTargetKind.Other => schoolAvailable && settings.BuffOther,
_ => false,
};
if (!wanted)
@ -141,7 +185,8 @@ public static class BuffPlan
if (!force
&& inForce.TryGetValue(line.Family, out var held)
&& held.Tier >= pick.Tier
&& held.Seconds >= settings.RebuffWhenUnderSeconds)
&& held.Seconds >= (rebuffWhenUnderSeconds
?? settings.RebuffWhenUnderSeconds))
{
continue; // already covered at this strength, and not expiring
}
@ -167,6 +212,91 @@ public static class BuffPlan
return ordered;
}
private static bool ProfileAllows(
BuffLine line,
BuffSettings settings,
bool bane)
{
int mode = bane
? settings.BaneProfileMode
: settings.ProtectionProfileMode;
string enabled = mode switch
{
1 => bane ? settings.BaneElements : settings.ProtectionElements,
2 => "ALFCBPS",
3 => string.Empty,
4 => "B",
5 => "BPS",
6 => "BPSA",
7 => "ALFC",
8 => "BPSAC",
_ => "ALFCBPS",
};
char element = ElementCode(line);
return element == '\0' || enabled.IndexOf(element) >= 0;
}
private static char ElementCode(BuffLine line)
{
string text = line.TargetName + " "
+ (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Name)
+ " "
+ (line.Tiers.Count == 0 ? string.Empty : line.Tiers[0].Description);
if (text.Contains("acid", StringComparison.OrdinalIgnoreCase))
return 'A';
if (text.Contains("lightning", StringComparison.OrdinalIgnoreCase)
|| text.Contains("electric", StringComparison.OrdinalIgnoreCase))
return 'L';
if (text.Contains("fire", StringComparison.OrdinalIgnoreCase))
return 'F';
if (text.Contains("cold", StringComparison.OrdinalIgnoreCase)
|| text.Contains("frost", StringComparison.OrdinalIgnoreCase))
return 'C';
if (text.Contains("bludgeon", StringComparison.OrdinalIgnoreCase))
return 'B';
if (text.Contains("pierc", StringComparison.OrdinalIgnoreCase))
return 'P';
if (text.Contains("slash", StringComparison.OrdinalIgnoreCase))
return 'S';
return '\0';
}
private static bool IsMagicSchoolName(string name) =>
name.Equals("Item Enchantment", StringComparison.OrdinalIgnoreCase)
|| name.Equals("Creature Enchantment", StringComparison.OrdinalIgnoreCase)
|| name.Equals("Life Magic", StringComparison.OrdinalIgnoreCase);
private static bool IsSchoolAvailable(
uint school,
IReadOnlyList<PluginSkillInfo> skills,
BuffSettings settings,
int characterLevel)
{
if (school is not (ItemEnchantmentSkill
or CreatureEnchantmentSkill
or LifeMagicSkill))
{
return true;
}
foreach (PluginSkillInfo skill in skills)
{
if (skill.SkillId == school
&& skill.Training is PluginSkillTraining.Trained
or PluginSkillTraining.Specialized)
{
return true;
}
}
int limit = school switch
{
ItemEnchantmentSkill => settings.BuffWithUntrainedItemSkill,
CreatureEnchantmentSkill => settings.BuffWithUntrainedCreatureSkill,
LifeMagicSkill => settings.BuffWithUntrainedLifeSkill,
_ => int.MaxValue,
};
return characterLevel <= limit;
}
/// <summary>Skill ids of the three schools that carry self-buffs.</summary>
private const uint CreatureEnchantmentSkill = 31;
private const uint ItemEnchantmentSkill = 32;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,173 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal enum CombatSuppressionReason
{
None,
Blacklisted,
Ghost,
}
/// <summary>
/// Implements VTank's three distinct unhittable-target guards. “Ghost” is
/// session-persistent until the object disappears; a normal blacklist expires
/// after the configured timeout.
/// </summary>
internal sealed class CombatFailureTracker
{
private readonly Dictionary<uint, Entry> _entries = [];
public IReadOnlyList<uint> ObserveTargets(
IReadOnlyList<PluginCombatTarget> targets,
double now,
CombatSettings settings)
{
var live = new HashSet<uint>();
List<uint>? newlyGhosted = null;
foreach (PluginCombatTarget target in targets)
{
live.Add(target.ObjectId);
if (!_entries.TryGetValue(target.ObjectId, out Entry? entry)
|| entry.Incarnation != target.Incarnation)
{
_entries[target.ObjectId] = entry = new Entry
{
Incarnation = target.Incarnation,
};
}
entry.LastSeenAt = now;
if (target.HealthRevision != 0
&& target.HealthRevision != entry.HealthRevision)
{
entry.HealthRevision = target.HealthRevision;
entry.SuccessfulMisses = 0;
entry.SpellStartFailures = 0;
}
if (entry.BlacklistedUntil <= now)
entry.BlacklistedUntil = 0d;
if (settings.DeleteGhostMonstersByHealthTracker
&& entry.EngagedAt is double engagedAt
&& now - engagedAt
>= Math.Max(0d, settings.GhostDeleteHealthTrackerSeconds)
&& target.IsHealthKnown
&& target.SecondsSinceHealthUpdate
>= Math.Max(0d, settings.GhostDeleteHealthTrackerSeconds))
{
if (!entry.IsGhost)
{
entry.IsGhost = true;
(newlyGhosted ??= []).Add(target.ObjectId);
}
}
}
foreach (uint objectId in _entries.Keys.ToArray())
{
Entry entry = _entries[objectId];
if (!live.Contains(objectId)
&& now - entry.LastSeenAt > Math.Max(
300d,
settings.BlacklistMonsterTimeoutSeconds))
{
_entries.Remove(objectId);
}
}
return newlyGhosted ?? (IReadOnlyList<uint>)Array.Empty<uint>();
}
public void BeginEngagement(uint objectId, double now)
{
if (objectId == 0u)
return;
Entry entry = Get(objectId);
entry.EngagedAt ??= now;
}
public bool RecordSpellDidNotStart(
uint objectId,
CombatSettings settings)
{
if (objectId == 0u || !settings.DeleteGhostMonsters)
return false;
Entry entry = Get(objectId);
entry.SpellStartFailures++;
if (entry.SpellStartFailures
>= Math.Max(1, settings.GhostMonsterSpellAttemptCount))
{
if (!entry.IsGhost)
{
entry.IsGhost = true;
return true;
}
}
return false;
}
public void RecordSuccessfulAttack(
uint objectId,
double now,
CombatSettings settings)
{
if (objectId == 0u)
return;
Entry entry = Get(objectId);
if (entry.HealthRevision > entry.AttackHealthRevision)
{
entry.SuccessfulMisses = 0;
return;
}
entry.SuccessfulMisses++;
if (entry.SuccessfulMisses
>= Math.Max(1, settings.BlacklistMonsterAttemptCount))
{
entry.BlacklistedUntil = now + Math.Max(
0d,
settings.BlacklistMonsterTimeoutSeconds);
entry.SuccessfulMisses = 0;
}
}
public void BeginAttack(uint objectId, long healthRevision)
{
if (objectId == 0u)
return;
Entry entry = Get(objectId);
entry.AttackHealthRevision = healthRevision;
}
public CombatSuppressionReason Reason(uint objectId, double now)
{
if (!_entries.TryGetValue(objectId, out Entry? entry))
return CombatSuppressionReason.None;
if (entry.IsGhost)
return CombatSuppressionReason.Ghost;
return entry.BlacklistedUntil > now
? CombatSuppressionReason.Blacklisted
: CombatSuppressionReason.None;
}
public void Reset() => _entries.Clear();
private Entry Get(uint objectId)
{
if (!_entries.TryGetValue(objectId, out Entry? entry))
_entries[objectId] = entry = new Entry();
return entry;
}
private sealed class Entry
{
public ushort Incarnation;
public double LastSeenAt;
public long HealthRevision;
public int SuccessfulMisses;
public int SpellStartFailures;
public long AttackHealthRevision;
public double? EngagedAt;
public double BlacklistedUntil;
public bool IsGhost;
}
}

View file

@ -0,0 +1,224 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal enum CombatDebuffSourceKind
{
LearnedSpell,
CasterItem,
ProcWeapon,
Grenade,
}
internal readonly record struct CombatDebuffSource(
DebuffIdentity Identity,
PluginSpellInfo Spell,
CombatDebuffSourceKind Kind,
uint ItemObjectId,
int SourceSkill,
int ActionOrder)
{
public bool UsesItem => Kind != CombatDebuffSourceKind.LearnedSpell;
}
/// <summary>
/// Port of official VTank <c>dz.b.CompareTo</c> plus <c>dz.a(MySpell,f7)</c>
/// source discovery. It considers only Items/Consumables profile members,
/// matches by real debuff identity, and gives a direct learned spell the exact
/// final tie-break preference VTank does.
/// </summary>
internal static class CombatItemDebuffPlanner
{
private const uint MeleeWeapon = 0x00000001u;
private const uint MissileWeapon = 0x00000100u;
private const uint Caster = 0x00008000u;
private const uint WarMagicSkill = 34u;
private const uint VoidMagicSkill = 43u;
private const uint AlchemySkill = 38u;
public static IReadOnlyList<CombatDebuffSource> Candidates(
MonsterRuleActions actions,
CombatSettings settings,
ICharacterInfo character,
ISpellCatalog spells,
IReadOnlyList<PluginInventoryItem> items,
Func<DebuffIdentity, PluginSpellInfo, bool> isDue)
{
ArgumentNullException.ThrowIfNull(actions);
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(spells);
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(isDue);
HashSet<DebuffIdentity> required = DebuffSpellCatalog.Required(actions);
if (required.Count == 0)
return Array.Empty<CombatDebuffSource>();
var result = new List<CombatDebuffSource>();
foreach (PluginSpellInfo spell in spells.KnownCombatSpells)
{
AddIfRequired(
result,
required,
spell,
CombatDebuffSourceKind.LearnedSpell,
0u,
CurrentSkill(character, spell.School),
isDue);
}
foreach (PluginInventoryItem item in items)
{
if (settings.CombatItemObjectIds.Contains(item.ObjectId)
|| settings.CombatItemNames.Contains(item.Name))
AddProfileItem(result, required, item, spells, isDue);
if (settings.ConsumableNames.Contains(item.Name))
AddGrenade(result, required, item, character, spells, isDue);
}
result.Sort((left, right) => Compare(
left,
right,
settings.DebuffSelectionMethod));
return result;
}
private static void AddProfileItem(
ICollection<CombatDebuffSource> result,
IReadOnlySet<DebuffIdentity> required,
PluginInventoryItem item,
ISpellCatalog spells,
Func<DebuffIdentity, PluginSpellInfo, bool> isDue)
{
if ((item.ItemType & Caster) != 0u
&& item.SpellId != 0u
&& spells.TryGet(item.SpellId, out PluginSpellInfo casterSpell))
{
AddIfRequired(
result,
required,
casterSpell,
CombatDebuffSourceKind.CasterItem,
item.ObjectId,
item.ItemSpellcraft,
isDue);
return;
}
if ((item.ItemType & (MeleeWeapon | MissileWeapon)) == 0u)
return;
foreach (uint spellId in item.AppraisedSpellIds)
{
if (!spells.TryGet(spellId, out PluginSpellInfo proc)
|| !proc.IsOffensive
|| proc.IsUntargeted
|| proc.School is WarMagicSkill or VoidMagicSkill)
{
continue;
}
AddIfRequired(
result,
required,
proc,
CombatDebuffSourceKind.ProcWeapon,
item.ObjectId,
item.ItemSpellcraft,
isDue);
// ga.a uses the first qualifying item spell.
return;
}
}
private static void AddGrenade(
ICollection<CombatDebuffSource> result,
IReadOnlySet<DebuffIdentity> required,
PluginInventoryItem item,
ICharacterInfo character,
ISpellCatalog spells,
Func<DebuffIdentity, PluginSpellInfo, bool> isDue)
{
if ((item.ItemType & MissileWeapon) == 0u
|| item.CombatUse != 0
|| !GrenadeCatalog.TryGet(item.Name, out GrenadeDefinition grenade)
|| CurrentSkill(character, AlchemySkill) < grenade.RequiredAlchemy
|| !spells.TryGet(grenade.SpellId, out PluginSpellInfo spell))
{
return;
}
AddIfRequired(
result,
required,
spell,
CombatDebuffSourceKind.Grenade,
item.ObjectId,
grenade.Spellcraft,
isDue);
}
private static void AddIfRequired(
ICollection<CombatDebuffSource> result,
IReadOnlySet<DebuffIdentity> required,
PluginSpellInfo spell,
CombatDebuffSourceKind kind,
uint itemObjectId,
int sourceSkill,
Func<DebuffIdentity, PluginSpellInfo, bool> isDue)
{
if (!DebuffSpellCatalog.TryClassify(
spell,
out DebuffIdentity identity,
out int actionOrder)
|| !required.Contains(identity)
|| !isDue(identity, spell))
{
return;
}
result.Add(new CombatDebuffSource(
identity,
spell,
kind,
itemObjectId,
sourceSkill,
actionOrder));
}
private static int Compare(
CombatDebuffSource left,
CombatDebuffSource right,
DebuffSelectionMethod selection)
{
if (selection == DebuffSelectionMethod.Skill)
{
int skill = right.SourceSkill.CompareTo(left.SourceSkill);
if (skill != 0)
return skill;
int quality = right.Spell.Quality.CompareTo(left.Spell.Quality);
if (quality != 0)
return quality;
}
else
{
int quality = right.Spell.Quality.CompareTo(left.Spell.Quality);
if (quality != 0)
return quality;
int skill = right.SourceSkill.CompareTo(left.SourceSkill);
if (skill != 0)
return skill;
}
bool leftDirect = left.Kind == CombatDebuffSourceKind.LearnedSpell;
bool rightDirect = right.Kind == CombatDebuffSourceKind.LearnedSpell;
if (leftDirect != rightDirect)
return leftDirect ? -1 : 1;
int action = left.ActionOrder.CompareTo(right.ActionOrder);
return action != 0
? action
: left.ItemObjectId.CompareTo(right.ItemObjectId);
}
private static int CurrentSkill(ICharacterInfo character, uint skillId) =>
character.TryGetSkill(skillId, out PluginSkillInfo skill)
? checked((int)skill.Current)
: 0;
}

View file

@ -0,0 +1,151 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal enum TargetSelectionMethod
{
Range,
Angle,
Both,
}
internal enum DebuffEachFirst
{
One = 1,
Priority = 2,
All = 3,
}
internal enum DebuffSelectionMethod
{
SpellLevel = 1,
Skill = 2,
}
internal enum PetRangeMode
{
AttackDistance = 0,
Custom = 1,
}
internal enum ConsumableCategory
{
Other,
HealthKit,
HealthFood,
StaminaKit,
StaminaFood,
ManaKit,
ManaFood,
Pea,
AllPeas,
Lockpick,
}
internal sealed class CombatSettings
{
/// <summary>
/// VTank's EnableCombat profile option. This is deliberately separate
/// from the panel's Run Macro state: a running macro may navigate, loot,
/// buff, or execute Meta rules while combat itself is disabled.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>VTank's hunt-cast skill margin.</summary>
public int HuntSkillExcessOverDifficulty { get; set; } = 25;
public float MaximumRange { get; set; } = 5f;
/// <summary>
/// Monsters nearer than this are not valid attack targets. VTank applies
/// this before priority and angle/range ranking.
/// </summary>
public float MinimumRange { get; set; }
/// <summary>
/// VTank's Approach Distance. Zero disables monster approach; otherwise
/// navigation may close a selected target from this range down to
/// <see cref="MaximumRange"/>.
/// </summary>
public float ApproachDistance { get; set; }
public bool IdlePeaceMode { get; set; }
public bool StopMacroOnDeath { get; set; } = true;
public bool JumpOutWandCasting { get; set; }
public bool DoJiggle { get; set; }
public TargetSelectionMethod SelectionMethod { get; set; } =
TargetSelectionMethod.Both;
public float TargetSelectAngleRange { get; set; } = 5f;
public bool TargetLock { get; set; }
public PluginAttackHeight AttackHeight { get; set; } =
PluginAttackHeight.Medium;
public float AttackPower { get; set; } = 0.5f;
public bool AutoAttackPower { get; set; } = true;
public bool UseRecklessness { get; set; } = true;
public double ScanIntervalSeconds { get; set; } = 0.25;
public DebuffEachFirst DebuffEachFirst { get; set; } = DebuffEachFirst.One;
public DebuffSelectionMethod DebuffSelectionMethod { get; set; } =
DebuffSelectionMethod.Skill;
public double DebuffPrecastSeconds { get; set; } = 5d;
public bool SwitchWandsToDebuff { get; set; }
public bool UseArcs { get; set; } = true;
public float SpellRangeFudge { get; set; } = 1f;
public bool UseBreakableTurnTo { get; set; } = true;
public bool UseProjectileAwareness { get; set; } = true;
public float CollisionProjectileRadius { get; set; } = 0.4f;
public float CollisionStepDistance { get; set; } = 0.7f;
public bool ShowCollisionDebug { get; set; }
public int MaximumCollisionChecksPerTick { get; set; } = 500;
public float ArcRange { get; set; } = 5f;
public float RingDistance { get; set; } = 5f;
public int MinimumRingTargets { get; set; } = 4;
public bool DeleteGhostMonsters { get; set; } = true;
public int GhostMonsterSpellAttemptCount { get; set; } = 200;
public int BlacklistMonsterAttemptCount { get; set; } = 4;
public double BlacklistMonsterTimeoutSeconds { get; set; } = 120d;
public bool DeleteGhostMonstersByHealthTracker { get; set; } = true;
public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d;
public bool SummonPets { get; set; } = true;
public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance;
public float PetCustomRange { get; set; } = 5f;
public int PetMonsterDensity { get; set; } = 1;
public int PetRefillCountIdle { get; set; } = 3;
public int PetRefillCountNormal { get; set; } = 1;
public bool AllowDebuffFallback { get; set; }
public int UseSpecialAmmo { get; set; }
public bool WhoYouGonnaCall { get; set; } = true;
public bool AutoFellowManagement { get; set; } = true;
public string BlacklistedSpellComponents { get; set; } = string.Empty;
/// <summary>
/// Runtime object ids resolved from VTank's Items profile. Debuff lenses
/// and cast-on-strike weapons are never taken from arbitrary inventory.
/// </summary>
public ISet<uint> CombatItemObjectIds { get; } = new HashSet<uint>();
public ISet<string> CombatItemNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
/// <summary>Exact names enabled in VTank's Consumables profile.</summary>
public ISet<string> ConsumableNames { get; } =
new HashSet<string>(StringComparer.Ordinal);
public IDictionary<string, ConsumableCategory> ConsumableCategories { get; } =
new Dictionary<string, ConsumableCategory>(StringComparer.Ordinal);
public IList<MonsterRule> Rules { get; } =
new List<MonsterRule> { new("DEFAULT", 0) };
public ResolvedMonsterRule ResolveRule(PluginCombatTarget target)
{
var context = new MonsterExpressionContext(
target.Name,
target.WeenieClassId,
target.SpeciesName,
target.MaximumHealth,
target.Distance,
target.HasShield,
MetaState,
ResolveSetting);
return MonsterRuleResolver.Resolve(Rules, context);
}
public string MetaState { get; set; } = "Default";
public IDictionary<string, MonsterValue> DynamicSettings { get; } =
new Dictionary<string, MonsterValue>(StringComparer.OrdinalIgnoreCase);
private MonsterValue? ResolveSetting(string name) =>
DynamicSettings.TryGetValue(name, out MonsterValue value)
? value
: null;
}

View file

@ -0,0 +1,649 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal readonly record struct CraftingPlan(
VtankCraftRecipe Recipe,
uint FirstObjectId,
uint SecondObjectId,
string DesiredResult)
{
public bool RequiresSplitFirstStack { get; init; }
public uint SplitContainerObjectId { get; init; }
}
internal static class ConsumableClassifier
{
private const uint HealingKitPublicFlag = 0x00010000u;
private const uint LockpickPublicFlag = 0x00020000u;
public static ConsumableCategory Classify(in PluginInventoryItem item)
{
if (item.Name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal))
return ConsumableCategory.AllPeas;
if (item.Name.EndsWith(" Pea", StringComparison.Ordinal))
return ConsumableCategory.Pea;
if ((item.PublicFlags & LockpickPublicFlag) != 0u)
return ConsumableCategory.Lockpick;
if ((item.PublicFlags & HealingKitPublicFlag) != 0u)
return KitCategory(item.Name);
return item.BoosterVital switch
{
2 => ConsumableCategory.HealthFood,
4 => ConsumableCategory.StaminaFood,
6 => ConsumableCategory.ManaFood,
_ => ClassifyName(item.Name),
};
}
public static ConsumableCategory ClassifyName(string name)
{
if (name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal))
return ConsumableCategory.AllPeas;
if (name.EndsWith(" Pea", StringComparison.Ordinal))
return ConsumableCategory.Pea;
return name.EndsWith(" Kit", StringComparison.Ordinal)
? KitCategory(name)
: ConsumableCategory.Other;
}
private static ConsumableCategory KitCategory(string name) => name switch
{
"Medicated Stamina Kit" or "Eternal Stamina Kit"
or "Greater Stamina Kit" or "Lesser Stamina Kit" =>
ConsumableCategory.StaminaKit,
"Medicated Mana Kit" or "Eternal Mana Kit"
or "Greater Mana Kit" or "Lesser Mana Kit" =>
ConsumableCategory.ManaKit,
_ => ConsumableCategory.HealthKit,
};
}
internal static class CraftingPlanner
{
public const string AllPeas = "[All Peas]";
public static CraftingPlan? Plan(
IReadOnlyList<PluginInventoryItem> inventory,
IEnumerable<string> desiredResults,
ICharacterInfo character,
int desiredCount = 1,
int arrowheadFletchDifficultyExcess = 10)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(desiredResults);
ArgumentNullException.ThrowIfNull(character);
var counts = inventory
.GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
static group => group.Key,
static group => group.Sum(item => Math.Max(1, item.StackSize)),
StringComparer.OrdinalIgnoreCase);
foreach (string desired in desiredResults
.Where(static name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static name => name, StringComparer.OrdinalIgnoreCase))
{
if (counts.GetValueOrDefault(desired) >= Math.Max(1, desiredCount))
continue;
CraftingPlan? plan = FindStep(
desired,
desired,
inventory,
character,
counts,
new HashSet<string>(StringComparer.OrdinalIgnoreCase),
arrowheadFletchDifficultyExcess);
if (plan is not null)
return plan;
}
return null;
}
public static CraftingPlan? PlanPeaSplit(
IReadOnlyList<PluginInventoryItem> inventory,
ISet<string> consumableProfile,
int minimumComponentCount)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(consumableProfile);
int minimum = Math.Max(0, minimumComponentCount);
if (minimum == 0)
return null;
PluginInventoryItem tool = Find(inventory, "Splitting Tool");
if (tool.ObjectId == 0u)
return null;
bool allPeas = consumableProfile.Contains(AllPeas);
var counts = inventory
.GroupBy(static item => item.Name, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
static group => group.Key,
static group => group.Sum(item => Math.Max(1, item.StackSize)),
StringComparer.OrdinalIgnoreCase);
foreach (VtankCraftRecipe recipe in VtankCraftDatabase.Recipes)
{
if (!recipe.FirstItem.Equals("Splitting Tool", StringComparison.Ordinal)
|| !recipe.SecondItem.EndsWith(" Pea", StringComparison.Ordinal)
|| (!allPeas && !consumableProfile.Contains(recipe.SecondItem))
|| counts.GetValueOrDefault(recipe.ResultItem) >= minimum)
{
continue;
}
PluginInventoryItem pea = Find(inventory, recipe.SecondItem);
if (pea.ObjectId == 0u)
continue;
return new CraftingPlan(
recipe,
tool.ObjectId,
pea.ObjectId,
recipe.ResultItem);
}
return null;
}
private static CraftingPlan? FindStep(
string result,
string desiredResult,
IReadOnlyList<PluginInventoryItem> inventory,
ICharacterInfo character,
IReadOnlyDictionary<string, int> counts,
HashSet<string> visiting,
int arrowheadFletchDifficultyExcess)
{
if (!visiting.Add(result))
return null;
try
{
foreach (VtankCraftRecipe recipe in VtankCraftDatabase.ForResult(result))
{
if (!HasRequiredSkill(
character,
recipe.RequiredSkill,
recipe.Difficulty,
arrowheadFletchDifficultyExcess))
continue;
PluginInventoryItem first = Find(inventory, recipe.FirstItem);
if (first.ObjectId == 0u)
{
CraftingPlan? prerequisite = FindStep(
recipe.FirstItem,
desiredResult,
inventory,
character,
counts,
visiting,
arrowheadFletchDifficultyExcess);
if (prerequisite is not null)
return prerequisite;
continue;
}
PluginInventoryItem second = Find(
inventory,
recipe.SecondItem,
excludedObjectId: recipe.FirstItem.Equals(
recipe.SecondItem,
StringComparison.OrdinalIgnoreCase)
? first.ObjectId
: 0u);
if (second.ObjectId == 0u)
{
if (recipe.FirstItem.Equals(
recipe.SecondItem,
StringComparison.OrdinalIgnoreCase)
&& first.StackSize >= 2)
{
return new CraftingPlan(
recipe,
first.ObjectId,
0u,
desiredResult)
{
RequiresSplitFirstStack = true,
SplitContainerObjectId = first.ContainerObjectId,
};
}
CraftingPlan? prerequisite = FindStep(
recipe.SecondItem,
desiredResult,
inventory,
character,
counts,
visiting,
arrowheadFletchDifficultyExcess);
if (prerequisite is not null)
return prerequisite;
continue;
}
return new CraftingPlan(
recipe,
first.ObjectId,
second.ObjectId,
desiredResult);
}
return null;
}
finally
{
visiting.Remove(result);
}
}
private static PluginInventoryItem Find(
IReadOnlyList<PluginInventoryItem> inventory,
string name,
uint excludedObjectId = 0u)
{
foreach (PluginInventoryItem item in inventory)
{
if (item.ObjectId != excludedObjectId
&& item.StackSize > 0
&& item.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
{
return item;
}
}
return default;
}
private static bool HasRequiredSkill(
ICharacterInfo character,
uint requiredSkill,
int difficulty,
int arrowheadFletchDifficultyExcess)
{
if (requiredSkill == 0u)
return true;
if (!character.TryGetSkill(requiredSkill, out PluginSkillInfo skill)
|| skill.Training is not (PluginSkillTraining.Trained
or PluginSkillTraining.Specialized))
{
return false;
}
return requiredSkill != 37u
|| skill.Current >= Math.Max(0, difficulty)
+ arrowheadFletchDifficultyExcess;
}
}
internal sealed class CraftingController
{
private const double SplitTimeoutSeconds = 10d;
private readonly IPluginHost _host;
private readonly InventorySettings _settings;
private readonly CombatSettings _profiles;
private CraftingPlan? _pending;
private CraftingPlan? _pendingSplit;
private long _observedCompletion;
private long _observedInventoryCompletion;
private double _untilScan;
private double _untilCriticalScan;
private double _untilIdleScan;
private double _splitElapsed;
private bool _splitAcknowledged;
public CraftingController(
IPluginHost host,
InventorySettings settings,
CombatSettings profiles)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_profiles = profiles ?? throw new ArgumentNullException(nameof(profiles));
}
public string Status { get; private set; } = "AutoCraft idle";
/// <summary>
/// Immediate VTank subsystem request, used by ammunition selection. This
/// bypasses the general AutoCraftItems toggle just as bv.cs does, while
/// still using the one canonical crafting transaction state machine.
/// </summary>
public bool Request(string resultName, int desiredCount = 1)
{
if (string.IsNullOrWhiteSpace(resultName)
|| _pending is not null
|| _pendingSplit is not null
|| !_host.Automation.IsAvailable)
{
return false;
}
IItemAutomation items = _host.Automation.Items;
if (!items.IsAvailable || items.IsBusy)
return false;
CraftingPlan? plan = CraftingPlanner.Plan(
items.CaptureOwnedItems(),
[resultName],
_host.Automation.Character,
desiredCount,
_settings.ArrowheadFletchDifficultyExcess);
return plan is { } next && Start(items, next);
}
public bool CanRequest(string resultName, int desiredCount = 1)
{
if (string.IsNullOrWhiteSpace(resultName)
|| !_host.Automation.IsAvailable
|| !_host.Automation.Items.IsAvailable)
{
return false;
}
return CraftingPlanner.Plan(
_host.Automation.Items.CaptureOwnedItems(),
[resultName],
_host.Automation.Character,
desiredCount,
_settings.ArrowheadFletchDifficultyExcess)
is not null;
}
public bool TickCritical(double elapsedSeconds, bool canAct)
{
IItemAutomation items = _host.Automation.Items;
ObserveCompletion(items);
if (ObserveSplitCompletion(items, elapsedSeconds))
return true;
if (_pending is not null)
{
if (items.IsBusy)
return true;
_pending = null;
}
if (!canAct
|| !_settings.AutoCraftItems
|| !_host.Automation.IsAvailable
|| !items.IsAvailable
|| items.IsBusy)
{
return false;
}
_untilCriticalScan -= Math.Max(0d, elapsedSeconds);
if (_untilCriticalScan > 0d)
return false;
_untilCriticalScan = Math.Max(0.1d, _settings.ScanIntervalSeconds);
IReadOnlyList<PluginInventoryItem> inventory = items.CaptureOwnedItems();
CraftingPlan? plan = _settings.SplitPeas
? CraftingPlanner.PlanPeaSplit(
inventory,
_profiles.ConsumableNames,
_settings.CriticalComponentMinimum)
: null;
plan ??= PlanCategoryCraft(
inventory,
idleCounts: false);
return plan is { } next && Start(items, next);
}
public bool Tick(double elapsedSeconds, bool canAct)
{
IItemAutomation items = _host.Automation.Items;
ObserveCompletion(items);
if (ObserveSplitCompletion(items, elapsedSeconds))
return true;
if (_pending is not null)
{
if (items.IsBusy)
return true;
_pending = null;
}
if (!canAct
|| !_settings.AutoCraftItems
|| !_host.Automation.IsAvailable
|| !items.IsAvailable
|| items.IsBusy)
{
return false;
}
_untilScan -= Math.Max(0d, elapsedSeconds);
if (_untilScan > 0d)
return false;
_untilScan = Math.Max(0.1d, _settings.ScanIntervalSeconds);
IReadOnlyList<PluginInventoryItem> inventory = items.CaptureOwnedItems();
CraftingPlan? plan = _settings.SplitPeas
? CraftingPlanner.PlanPeaSplit(
inventory,
_profiles.ConsumableNames,
_settings.NormalComponentMinimum)
: null;
plan ??= CraftingPlanner.Plan(
inventory,
_profiles.ConsumableNames
.Concat(_profiles.CombatItemNames)
.Where(static name =>
!name.Equals(CraftingPlanner.AllPeas, StringComparison.Ordinal)
&& !name.EndsWith(" Pea", StringComparison.Ordinal)),
_host.Automation.Character,
arrowheadFletchDifficultyExcess:
_settings.ArrowheadFletchDifficultyExcess);
if (plan is not { } next)
{
Status = "AutoCraft idle";
return false;
}
return Start(items, next);
}
public bool TickIdle(double elapsedSeconds, bool canAct)
{
IItemAutomation items = _host.Automation.Items;
ObserveCompletion(items);
if (ObserveSplitCompletion(items, elapsedSeconds))
return true;
if (_pending is not null)
{
if (items.IsBusy)
return true;
_pending = null;
}
if (!canAct
|| !_settings.AutoCraftItems
|| !_host.Automation.IsAvailable
|| !items.IsAvailable
|| items.IsBusy)
{
return false;
}
_untilIdleScan -= Math.Max(0d, elapsedSeconds);
if (_untilIdleScan > 0d)
return false;
_untilIdleScan = Math.Max(0.1d, _settings.ScanIntervalSeconds);
IReadOnlyList<PluginInventoryItem> inventory = items.CaptureOwnedItems();
CraftingPlan? plan = _settings.SplitPeas
? CraftingPlanner.PlanPeaSplit(
inventory,
_profiles.ConsumableNames,
_settings.IdleComponentMinimum)
: null;
plan ??= PlanCategoryCraft(inventory, idleCounts: true);
return plan is { } next && Start(items, next);
}
private CraftingPlan? PlanCategoryCraft(
IReadOnlyList<PluginInventoryItem> inventory,
bool idleCounts)
{
foreach (string name in _profiles.ConsumableNames
.OrderBy(static name => name, StringComparer.Ordinal))
{
ConsumableCategory category = _profiles.ConsumableCategories
.TryGetValue(name, out ConsumableCategory stored)
? stored
: ConsumableClassifier.ClassifyName(name);
int desired = idleCounts ? IdleCount(category) : category switch
{
ConsumableCategory.HealthKit
or ConsumableCategory.HealthFood
or ConsumableCategory.StaminaKit
or ConsumableCategory.StaminaFood
or ConsumableCategory.ManaKit
or ConsumableCategory.ManaFood => 1,
_ => 0,
};
if (desired <= 0)
continue;
CraftingPlan? plan = CraftingPlanner.Plan(
inventory,
[name],
_host.Automation.Character,
desired,
_settings.ArrowheadFletchDifficultyExcess);
if (plan is not null)
return plan;
}
return null;
}
private int IdleCount(ConsumableCategory category) => category switch
{
ConsumableCategory.HealthKit => _settings.IdleHealthKitCount,
ConsumableCategory.StaminaKit => _settings.IdleStaminaKitCount,
ConsumableCategory.ManaKit => _settings.IdleManaKitCount,
ConsumableCategory.HealthFood => _settings.IdleHealthFoodCount,
ConsumableCategory.StaminaFood => _settings.IdleStaminaFoodCount,
ConsumableCategory.ManaFood => _settings.IdleManaFoodCount,
_ => 0,
};
private bool Start(IItemAutomation items, CraftingPlan next)
{
if (next.RequiresSplitFirstStack)
{
long completionBefore = items.LastInventoryCompletion.Revision;
PluginItemCommandResult split = items.MoveToContainer(
next.FirstObjectId,
next.SplitContainerObjectId,
amount: 1u);
if (!split.Accepted)
{
Status = $"AutoCraft split waiting: {split.Status}";
return split.Status == PluginItemCommandStatus.Busy;
}
_pendingSplit = next;
_observedInventoryCompletion = completionBefore;
_splitElapsed = 0d;
_splitAcknowledged = false;
Status = $"Splitting {next.Recipe.FirstItem} for crafting";
return true;
}
PluginItemCommandResult result = items.Apply(
next.FirstObjectId,
next.SecondObjectId);
if (!result.Accepted)
{
Status = $"AutoCraft waiting: {result.Status}";
return result.Status == PluginItemCommandStatus.Busy;
}
_pending = next;
Status = $"Crafting {next.Recipe.ResultItem}";
return true;
}
public void Reset()
{
_pending = null;
_pendingSplit = null;
_untilScan = 0d;
_untilCriticalScan = 0d;
_untilIdleScan = 0d;
_splitElapsed = 0d;
_splitAcknowledged = false;
Status = "AutoCraft idle";
}
private void ObserveCompletion(IItemAutomation items)
{
PluginItemUseCompletion completion = items.LastCompletion;
if (completion.Revision == 0 || completion.Revision == _observedCompletion)
return;
_observedCompletion = completion.Revision;
if (_pending is not { } pending
|| completion.SourceObjectId != pending.FirstObjectId)
{
return;
}
Status = completion.IsSuccess
? $"Crafted {pending.Recipe.ResultItem}"
: $"Craft failed (0x{completion.WeenieError:X})";
_pending = null;
_untilScan = 0d;
_untilCriticalScan = 0d;
_untilIdleScan = 0d;
}
private bool ObserveSplitCompletion(
IItemAutomation items,
double elapsedSeconds)
{
if (_pendingSplit is not { } splitPlan)
return false;
_splitElapsed += Math.Max(0d, elapsedSeconds);
PluginInventoryCompletion completion = items.LastInventoryCompletion;
if (completion.Revision != 0
&& completion.Revision != _observedInventoryCompletion)
{
_observedInventoryCompletion = completion.Revision;
if (completion.SourceObjectId == splitPlan.FirstObjectId)
{
if (!completion.IsSuccess)
{
Status = $"AutoCraft split failed (0x{completion.WeenieError:X})";
ClearPendingSplit();
return true;
}
_splitAcknowledged = true;
}
}
if (_splitAcknowledged && TryStartAfterSplit(items, splitPlan))
return true;
if (_splitElapsed < SplitTimeoutSeconds)
{
Status = _splitAcknowledged
? "AutoCraft waiting for split inventory"
: $"Splitting {splitPlan.Recipe.FirstItem} for crafting";
return true;
}
Status = "AutoCraft split timed out";
ClearPendingSplit();
return true;
}
private bool TryStartAfterSplit(
IItemAutomation items,
CraftingPlan splitPlan)
{
PluginInventoryItem[] inputs = items.CaptureOwnedItems()
.Where(item => item.Name.Equals(
splitPlan.Recipe.FirstItem,
StringComparison.OrdinalIgnoreCase))
.OrderBy(static item => item.ObjectId)
.ToArray();
if (inputs.Length < 2)
return false;
CraftingPlan ready = splitPlan with
{
FirstObjectId = inputs[0].ObjectId,
SecondObjectId = inputs[1].ObjectId,
RequiresSplitFirstStack = false,
};
ClearPendingSplit();
return Start(items, ready);
}
private void ClearPendingSplit()
{
_pendingSplit = null;
_splitElapsed = 0d;
_splitAcknowledged = false;
_untilScan = Math.Max(0.1d, _settings.ScanIntervalSeconds);
_untilCriticalScan = Math.Max(0.1d, _settings.ScanIntervalSeconds);
_untilIdleScan = Math.Max(0.1d, _settings.ScanIntervalSeconds);
}
}

View file

@ -0,0 +1,400 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal readonly record struct DebuffIdentity(
MonsterActionFlags Flag,
MonsterDamageType DamageType);
internal readonly record struct DebuffChoice(
DebuffIdentity Identity,
PluginSpellInfo Spell,
int ActionOrder);
/// <summary>
/// Converts retail spell-table data into VTank's Monsters-column vocabulary.
/// Names are the stable retail identities VTank exposed to users; no host-side
/// combat policy leaks into the plugin API.
/// </summary>
internal sealed class DebuffSpellCatalog
{
private static readonly (MonsterActionFlags Flag, int Order)[] OrderedFlags =
[
(MonsterActionFlags.Fester, 0),
(MonsterActionFlags.Broadside, 1),
(MonsterActionFlags.GravityWell, 2),
(MonsterActionFlags.Imperil, 3),
(MonsterActionFlags.Yield, 4),
(MonsterActionFlags.Vulnerability, 5),
(MonsterActionFlags.WeakeningCurse, 6),
(MonsterActionFlags.FesteringCurse, 7),
(MonsterActionFlags.Corruption, 8),
(MonsterActionFlags.DestructiveCurse, 9),
(MonsterActionFlags.Corrosion, 10),
];
private readonly DebuffChoice[] _choices;
private DebuffSpellCatalog(DebuffChoice[] choices) => _choices = choices;
public static DebuffSpellCatalog Build(IReadOnlyList<PluginSpellInfo> spells)
{
ArgumentNullException.ThrowIfNull(spells);
var choices = new List<DebuffChoice>();
foreach (PluginSpellInfo spell in spells)
{
if (!TryClassify(spell, out DebuffIdentity identity, out int order))
continue;
choices.Add(new DebuffChoice(identity, spell, order));
}
return new DebuffSpellCatalog([.. choices]);
}
public IReadOnlyList<DebuffChoice> Candidates(
MonsterRuleActions actions,
DebuffSelectionMethod selection,
ICharacterInfo character,
Func<DebuffIdentity, PluginSpellInfo, bool> isDue)
{
ArgumentNullException.ThrowIfNull(actions);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(isDue);
HashSet<DebuffIdentity> required = Required(actions);
if (required.Count == 0 || _choices.Length == 0)
return Array.Empty<DebuffChoice>();
var candidates = new List<DebuffChoice>();
foreach (DebuffChoice choice in _choices)
{
if (required.Contains(choice.Identity)
&& isDue(choice.Identity, choice.Spell))
{
candidates.Add(choice);
}
}
candidates.Sort((left, right) => Compare(
left, right, selection, character));
return candidates;
}
public bool HasKnownRequirement(MonsterRuleActions actions)
{
HashSet<DebuffIdentity> required = Required(actions);
foreach (DebuffChoice choice in _choices)
{
if (required.Contains(choice.Identity))
return true;
}
return false;
}
private static int Compare(
DebuffChoice left,
DebuffChoice right,
DebuffSelectionMethod selection,
ICharacterInfo character)
{
if (selection == DebuffSelectionMethod.Skill)
{
uint leftSkill = Skill(character, left.Spell.School);
uint rightSkill = Skill(character, right.Spell.School);
int skill = rightSkill.CompareTo(leftSkill);
if (skill != 0)
return skill;
}
int tier = right.Spell.Tier.CompareTo(left.Spell.Tier);
if (tier != 0)
return tier;
int difficulty = right.Spell.Difficulty.CompareTo(left.Spell.Difficulty);
if (difficulty != 0)
return difficulty;
int action = left.ActionOrder.CompareTo(right.ActionOrder);
return action != 0
? action
: left.Spell.SpellId.CompareTo(right.Spell.SpellId);
}
private static uint Skill(ICharacterInfo character, uint skillId) =>
character.TryGetSkill(skillId, out PluginSkillInfo skill)
? skill.Current
: 0u;
internal static HashSet<DebuffIdentity> Required(MonsterRuleActions actions)
{
var required = new HashSet<DebuffIdentity>();
foreach ((MonsterActionFlags flag, _) in OrderedFlags)
{
if ((actions.Flags & flag) == 0)
continue;
MonsterDamageType damage = flag == MonsterActionFlags.Vulnerability
? actions.DamageType
: MonsterDamageType.Auto;
required.Add(new DebuffIdentity(flag, damage));
}
if ((actions.Flags & MonsterActionFlags.Vulnerability) != 0
&& actions.ExtraVulnerability != MonsterDamageType.Auto)
{
required.Add(new DebuffIdentity(
MonsterActionFlags.Vulnerability,
actions.ExtraVulnerability));
}
return required;
}
internal static bool TryClassify(
PluginSpellInfo spell,
out DebuffIdentity identity,
out int order)
{
string name = Normalize(spell.Name);
MonsterActionFlags flag;
MonsterDamageType damage = MonsterDamageType.Auto;
if (name.StartsWith("Fester Other", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.Fester;
else if (name.StartsWith("Broadside of a Barn", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.Broadside;
else if (name.StartsWith("Gravity Well", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.GravityWell;
else if (name.StartsWith("Imperil Other", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.Imperil;
else if (name.StartsWith("Magic Yield Other", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.Yield;
else if (name.Contains(" Vulnerability Other", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Vulnerability Other", StringComparison.OrdinalIgnoreCase)
|| IsClassicLure(name))
{
flag = MonsterActionFlags.Vulnerability;
damage = DamageFromName(name);
}
else if (name.StartsWith("Weakening Curse", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.WeakeningCurse;
else if (name.StartsWith("Festering Curse", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.FesteringCurse;
else if (name.StartsWith("Corruption", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.Corruption;
else if (name.StartsWith("Destructive Curse", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.DestructiveCurse;
else if (name.StartsWith("Corrosion", StringComparison.OrdinalIgnoreCase))
flag = MonsterActionFlags.Corrosion;
else
{
identity = default;
order = int.MaxValue;
return false;
}
order = Array.FindIndex(
OrderedFlags,
entry => entry.Flag == flag);
if (order < 0)
order = int.MaxValue;
identity = new DebuffIdentity(flag, damage);
return true;
}
private static string Normalize(string name)
{
const string incantation = "Incantation of ";
return name.StartsWith(incantation, StringComparison.OrdinalIgnoreCase)
? name[incantation.Length..]
: name;
}
/// <summary>
/// Retail's levels I-VII vulnerability line uses the older * Lure names.
/// Do not confuse it with the distinct Lure Blade item-enchantment line.
/// </summary>
private static bool IsClassicLure(string name) =>
name.StartsWith("Acid Lure", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Blade Lure", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Bludgeon Lure", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Flame Lure", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Frost Lure", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Lightning Lure", StringComparison.OrdinalIgnoreCase)
|| name.StartsWith("Piercing Lure", StringComparison.OrdinalIgnoreCase);
internal static MonsterDamageType DamageFromName(string name)
{
if (name.Contains("Blade", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Slash;
if (name.Contains("Piercing", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Pierce;
if (name.Contains("Bludgeon", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Bludgeon;
if (name.Contains("Cold", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Frost", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Cold;
if (name.Contains("Fire", StringComparison.OrdinalIgnoreCase)
|| name.Contains("Flame", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Fire;
if (name.Contains("Acid", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Acid;
if (name.Contains("Lightning", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Electric;
if (name.Contains("Nether", StringComparison.OrdinalIgnoreCase))
return MonsterDamageType.Nether;
return MonsterDamageType.Auto;
}
}
/// <summary>
/// Session-local VTank spell tracker. A debuff becomes active only after the
/// host publishes its matching server UseDone receipt.
/// </summary>
internal sealed class DebuffTracker
{
private readonly Dictionary<(uint Target, DebuffIdentity Identity), Applied> _applied = [];
private Pending? _pending;
private long _observedCompletionRevision;
public bool HasPending => _pending is not null;
public string PendingName => _pending?.Spell.Name ?? string.Empty;
public uint PendingTarget => _pending?.TargetObjectId ?? 0u;
public bool IsDue(
uint targetObjectId,
DebuffIdentity identity,
PluginSpellInfo spell,
double now,
double precastSeconds)
{
if (!_applied.TryGetValue((targetObjectId, identity), out Applied applied))
return true;
if (applied.SpellId != spell.SpellId && spell.Tier > applied.Tier)
return true;
double lead = spell.IsDamageOverTime ? 0d : Math.Max(0d, precastSeconds);
return now >= applied.ExpiresAt - lead;
}
public void Begin(
uint targetObjectId,
DebuffIdentity identity,
PluginSpellInfo spell,
double now,
long completionRevision)
{
_observedCompletionRevision = Math.Max(
_observedCompletionRevision,
completionRevision);
_pending = new Pending(targetObjectId, identity, spell, now);
}
public DebuffCompletion Observe(
PluginCastCompletion completion,
double now)
{
if (completion.Revision <= _observedCompletionRevision)
return default;
_observedCompletionRevision = completion.Revision;
if (_pending is not { } pending
|| pending.Spell.SpellId != completion.SpellId
|| pending.TargetObjectId != completion.TargetObjectId)
{
return default;
}
_pending = null;
if (!completion.IsSuccess)
{
return new DebuffCompletion(
Completed: true,
Succeeded: false,
pending.Spell.Name,
completion.WeenieError);
}
double duration = Math.Max(0d, pending.Spell.DurationSeconds);
_applied[(pending.TargetObjectId, pending.Identity)] = new Applied(
pending.Spell.SpellId,
pending.Spell.Tier,
now + duration);
return new DebuffCompletion(
Completed: true,
Succeeded: true,
pending.Spell.Name,
0u);
}
public bool ExpirePending(double now, double timeoutSeconds = 15d)
{
if (_pending is not { } pending
|| now - pending.DispatchedAt < timeoutSeconds)
{
return false;
}
_pending = null;
return true;
}
public void RecordApplied(
uint targetObjectId,
DebuffIdentity identity,
PluginSpellInfo spell,
double now)
{
double duration = Math.Max(0d, spell.DurationSeconds);
_applied[(targetObjectId, identity)] = new Applied(
spell.SpellId,
spell.Tier,
now + duration);
}
/// <summary>
/// VTank's <c>/vt fakeimp</c> records Gossamer Flesh locally for 3,000
/// seconds. It is deliberately stronger than every learnable Imperil tier
/// so the debug marker remains authoritative for its requested duration.
/// </summary>
public void RecordFakeImperil(uint targetObjectId, double now)
{
const uint gossamerFlesh = 0x081Au;
const double durationSeconds = 3000d;
_applied[(targetObjectId, new DebuffIdentity(
MonsterActionFlags.Imperil,
MonsterDamageType.Auto))] = new Applied(
gossamerFlesh,
int.MaxValue,
now + durationSeconds);
}
public void ClearPending() => _pending = null;
public void RetainTargets(IReadOnlySet<uint> liveTargets)
{
if (_applied.Count == 0)
return;
foreach ((uint Target, DebuffIdentity Identity) key in _applied.Keys.ToArray())
{
if (!liveTargets.Contains(key.Target))
_applied.Remove(key);
}
}
public void Reset()
{
_applied.Clear();
_pending = null;
_observedCompletionRevision = 0;
}
private readonly record struct Pending(
uint TargetObjectId,
DebuffIdentity Identity,
PluginSpellInfo Spell,
double DispatchedAt);
private readonly record struct Applied(
uint SpellId,
int Tier,
double ExpiresAt);
}
internal readonly record struct DebuffCompletion(
bool Completed,
bool Succeeded,
string SpellName,
uint WeenieError);

View file

@ -0,0 +1,420 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// VTank's post-buff dispel rules from c8.cs, cx.cs and af.cs. Policy lives in
/// the plugin; the host contributes only canonical spell, item, mode and
/// completion operations.
/// </summary>
internal sealed class DispelController
{
private const uint EradicateLifeMagicSelf =
(uint)SpellId.EradicateLifeMagicSelf;
private const double ActionTimeoutSeconds = 15d;
private const float AllyDispelRangeMeters = 5f;
private const uint CreatureEnchantmentSkill = 31u;
private const uint ArcaneLoreSkill = 14u;
private const uint DispelProtectionSpell = 3179u;
private static readonly string[] HighDifficultyItems =
[
"Rune of Dispel",
"Society Gem of Dispelling",
"Black Market Gem of Dispelling",
];
private static readonly string[] NormalDifficultyItems =
[
"Rune of Dispel",
"Chocolate Gromnie",
"Condensed Dispel Potion",
"Gem of Stillness",
];
private readonly IPluginHost _host;
private readonly VitalSettings _settings;
private Pending? _pending;
private double _pendingSeconds;
private double _retryDelay;
public DispelController(IPluginHost host, VitalSettings settings)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string Status { get; private set; } = "Dispel idle";
public bool Tick(double elapsedSeconds, bool canAct)
{
double elapsed = Math.Max(0d, elapsedSeconds);
_retryDelay = Math.Max(0d, _retryDelay - elapsed);
if (ObservePending(elapsed))
return true;
IAutomationSurface automation = _host.Automation;
if (!canAct
|| _retryDelay > 0d
|| !_host.Automation.IsAvailable
|| (!_settings.CastDispelSelf
&& !_settings.UseDispelItems
&& !_settings.UseDispelDrum)
|| automation.Magic.IsCasting
|| automation.Items.IsBusy)
{
return false;
}
if (_settings.CastDispelSelf
&& TryStartSelfDispel(automation))
{
return true;
}
if (_settings.UseDispelItems
&& TrySelectDispelItem(automation, out PluginInventoryItem item))
{
long revision = automation.Items.LastCompletion.Revision;
PluginItemCommandResult result = automation.Items.Use(item.ObjectId);
if (result.Accepted)
{
_pending = new Pending(
DispelSource.Item,
item.ObjectId,
item.Name,
revision);
_pendingSeconds = 0d;
Status = $"Using {item.Name}";
return true;
}
Status = $"Waiting to use {item.Name}";
return result.Status == PluginItemCommandStatus.Busy;
}
if (_settings.UseDispelDrum && TryStartAllyDispel(automation))
return true;
Status = "Dispel idle";
return false;
}
public void Reset()
{
_pending = null;
_pendingSeconds = 0d;
_retryDelay = 0d;
Status = "Dispel idle";
}
private bool TryStartSelfDispel(IAutomationSurface automation)
{
if (!automation.Spells.TryGet(
EradicateLifeMagicSelf,
out PluginSpellInfo spell)
|| !automation.Spells.IsKnown(EradicateLifeMagicSelf)
|| !HasVulnerabilityAtOrBelow(automation, spell.Difficulty)
|| !automation.Items.CaptureOwnedItems().Any(static item =>
item.StackSize > 0
&& item.Name.Equals("Chorizite", StringComparison.Ordinal)))
{
return false;
}
if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic)
{
PluginCombatCommandResult mode = automation.Combat.EnterMode(
PluginCombatMode.Magic);
Status = mode.Accepted
? "Switching to Magic for self dispel"
: "Waiting for Magic mode to self dispel";
return true;
}
uint target = automation.Character.ObjectId;
PluginCastGate gate = automation.Magic.EvaluateGate(
EradicateLifeMagicSelf,
target);
if (gate != PluginCastGate.Ready)
{
Status = "Waiting to cast Eradicate Life Magic Self";
return true;
}
long revision = automation.Magic.LastCompletion.Revision;
if (!automation.Magic.Cast(EradicateLifeMagicSelf, target))
{
Status = "Self dispel was refused";
_retryDelay = 0.25d;
return true;
}
_pending = new Pending(
DispelSource.Spell,
EradicateLifeMagicSelf,
spell.Name,
revision);
_pendingSeconds = 0d;
Status = $"Casting {spell.Name}";
return true;
}
private bool TrySelectDispelItem(
IAutomationSurface automation,
out PluginInventoryItem selected)
{
selected = default;
IReadOnlyList<PluginInventoryItem> inventory =
automation.Items.CaptureOwnedItems();
if (HasVulnerabilityAtOrBelow(automation, 400)
&& TryFind(inventory, HighDifficultyItems, out selected))
{
return true;
}
return HasVulnerabilityAtOrBelow(automation, 350)
&& TryFind(inventory, NormalDifficultyItems, out selected);
}
private static bool TryFind(
IReadOnlyList<PluginInventoryItem> inventory,
IEnumerable<string> names,
out PluginInventoryItem selected)
{
foreach (string name in names)
{
foreach (PluginInventoryItem item in inventory)
{
if (item.StackSize > 0
&& item.Name.Equals(name, StringComparison.Ordinal))
{
selected = item;
return true;
}
}
}
selected = default;
return false;
}
private bool TryStartAllyDispel(IAutomationSurface automation)
{
if (!automation.Fellowship.IsInFellowship
|| !TrySelectAwakener(automation, out PluginInventoryItem drum)
|| !TrySelectAlly(automation, out PluginFellowMember target))
{
return false;
}
if (automation.Combat.Snapshot.Mode != PluginCombatMode.Magic)
{
PluginCombatCommandResult mode = automation.Combat.EnterMode(
PluginCombatMode.Magic);
Status = mode.Accepted
? "Switching to Magic for ally dispel"
: "Waiting for Magic mode to dispel ally";
return true;
}
long revision = automation.Items.LastCompletion.Revision;
PluginItemCommandResult result = automation.Items.Apply(
drum.ObjectId,
target.ObjectId);
if (!result.Accepted)
{
Status = $"Waiting to use {drum.Name} on {target.Name}";
return result.Status == PluginItemCommandStatus.Busy;
}
_pending = new Pending(
DispelSource.AllyItem,
drum.ObjectId,
$"{drum.Name} on {target.Name}",
revision);
_pendingSeconds = 0d;
Status = $"Using {drum.Name} on {target.Name}";
return true;
}
private static bool TrySelectAwakener(
IAutomationSurface automation,
out PluginInventoryItem selected)
{
selected = default;
if (!automation.Character.TryGetSkill(
CreatureEnchantmentSkill,
out PluginSkillInfo creature)
|| !automation.Character.TryGetSkill(
ArcaneLoreSkill,
out PluginSkillInfo arcane)
|| arcane.Current < 110u)
{
return false;
}
foreach (PluginInventoryItem item in automation.Items.CaptureOwnedItems())
{
if (!item.IsEquipped)
continue;
bool valid = item.Name switch
{
"Awakener" => creature.Training == PluginSkillTraining.Specialized,
"Attenuated Awakener" => creature.Training
is PluginSkillTraining.Trained
or PluginSkillTraining.Specialized,
_ => false,
};
if (!valid)
continue;
selected = item;
return true;
}
return false;
}
private static bool TrySelectAlly(
IAutomationSurface automation,
out PluginFellowMember selected)
{
selected = default;
int highestScore = 0;
foreach (PluginFellowMember member in automation.Fellowship.CaptureMembers())
{
if (member.ObjectId == automation.Character.ObjectId
|| member.Distance > AllyDispelRangeMeters)
{
continue;
}
IReadOnlyList<PluginTrackedEnchantment> tracked =
automation.Enchantments.Capture(member.ObjectId);
if (tracked.Any(static enchantment =>
enchantment.SpellId == DispelProtectionSpell
&& enchantment.SecondsRemaining > 0d))
{
continue;
}
var qualities = new Dictionary<MonsterDamageType, int>();
foreach (PluginTrackedEnchantment enchantment in tracked)
{
if (enchantment.SecondsRemaining <= 0d
|| enchantment.IsUntargeted
|| !automation.Spells.TryGet(
enchantment.SpellId,
out PluginSpellInfo spell)
|| spell.Difficulty > 350
|| !DebuffSpellCatalog.TryClassify(
spell,
out DebuffIdentity identity,
out _)
|| identity.Flag != MonsterActionFlags.Vulnerability
|| identity.DamageType == MonsterDamageType.Auto)
{
continue;
}
int quality = enchantment.Quality;
if (!qualities.TryGetValue(identity.DamageType, out int old)
|| quality > old)
{
qualities[identity.DamageType] = quality;
}
}
int score = qualities.Values.Where(static quality => quality > 250).Sum();
if (score <= highestScore)
continue;
highestScore = score;
selected = member;
}
return selected.ObjectId != 0u;
}
private static bool HasVulnerabilityAtOrBelow(
IAutomationSurface automation,
int maximumDifficulty)
{
foreach (PluginActiveEnchantment active
in automation.Character.ActiveEnchantments)
{
if (active.SecondsRemaining < 0d
|| !automation.Spells.TryGet(active.SpellId, out PluginSpellInfo spell)
|| spell.Difficulty > maximumDifficulty
|| spell.IsUntargeted
|| !DebuffSpellCatalog.TryClassify(
spell,
out DebuffIdentity identity,
out _)
|| identity.Flag != MonsterActionFlags.Vulnerability)
{
continue;
}
return true;
}
return false;
}
private bool ObservePending(double elapsedSeconds)
{
if (_pending is not { } pending)
return false;
_pendingSeconds += elapsedSeconds;
if (pending.Source == DispelSource.Spell)
{
PluginCastCompletion completion = _host.Automation.Magic.LastCompletion;
if (completion.Revision > pending.Revision)
{
pending.Revision = completion.Revision;
if (completion.SpellId == pending.ObjectId)
return Finish(completion.IsSuccess, completion.WeenieError);
}
}
else
{
PluginItemUseCompletion completion = _host.Automation.Items.LastCompletion;
if (completion.Revision > pending.Revision)
{
pending.Revision = completion.Revision;
if (completion.SourceObjectId == pending.ObjectId)
return Finish(completion.IsSuccess, completion.WeenieError);
}
}
if (_pendingSeconds < ActionTimeoutSeconds)
return true;
Status = $"Dispel timed out: {pending.Name}";
ClearPending();
return true;
}
private bool Finish(bool succeeded, uint weenieError)
{
string name = _pending?.Name ?? "dispel";
Status = succeeded
? $"Dispel completed: {name}"
: $"Dispel failed (0x{weenieError:X}): {name}";
ClearPending();
return true;
}
private void ClearPending()
{
_pending = null;
_pendingSeconds = 0d;
_retryDelay = 0.25d;
}
private enum DispelSource
{
Spell,
Item,
AllyItem,
}
private sealed class Pending(
DispelSource source,
uint objectId,
string name,
long revision)
{
public DispelSource Source { get; } = source;
public uint ObjectId { get; } = objectId;
public string Name { get; } = name;
public long Revision { get; set; } = revision;
}
}

View file

@ -0,0 +1,653 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace AcDream.Plugins.MossTank.Expressions;
/// <summary>
/// Presentation- and game-independent UtilityBelt expression functions.
/// World queries and actions are registered by a separate capability adapter;
/// keeping this library pure makes Meta evaluation deterministic in tests and
/// prevents expression code from reaching around the plugin API.
/// </summary>
internal static class CoreExpressionFunctions
{
private static readonly Regex CoordinatePattern = new(
@"^\s*(?<ns>[-+]?\d+(?:\.\d+)?)\s*(?<nsdir>[NS])\s*,\s*"
+ @"(?<ew>[-+]?\d+(?:\.\d+)?)\s*(?<ewdir>[EW])"
+ @"(?:\s*,\s*(?<z>[-+]?\d+(?:\.\d+)?))?\s*$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
public static ExpressionFunctionRegistry CreateDefault(Random? random = null)
{
var registry = new ExpressionFunctionRegistry();
Register(registry, random);
return registry;
}
public static void Register(
ExpressionFunctionRegistry registry,
Random? random = null)
{
ArgumentNullException.ThrowIfNull(registry);
RegisterVariables(registry, ExpressionVariableScope.Session, string.Empty);
RegisterVariables(registry, ExpressionVariableScope.Persistent, "p");
RegisterVariables(registry, ExpressionVariableScope.Global, "g");
RegisterConversionsAndMath(registry, random ?? Random.Shared);
RegisterLists(registry);
RegisterDictionaries(registry);
RegisterCoordinates(registry);
RegisterTime(registry);
}
private static void RegisterVariables(
ExpressionFunctionRegistry registry,
ExpressionVariableScope scope,
string infix)
{
string get = "get" + infix + "var";
string set = "set" + infix + "var";
string test = "test" + infix + "var";
string touch = "touch" + infix + "var";
string clear = "clear" + infix + "var";
string clearAll = "clearall" + infix + "vars";
registry.Register(get, 1, 1, (context, args) =>
context.State.Get(scope, args[0].AsString(get)), $"{get}[name]");
registry.Register(set, 2, 2, (context, args) =>
context.State.Set(scope, args[0].AsString(set), args[1]),
$"{set}[name,value]");
registry.Register(test, 1, 1, (context, args) =>
ExpressionValue.Boolean(context.State.Contains(
scope,
args[0].AsString(test))), $"{test}[name]");
registry.Register(touch, 1, 1, (context, args) =>
{
string name = args[0].AsString(touch);
bool existed = context.State.Contains(scope, name);
if (!existed)
context.State.Set(scope, name, ExpressionValue.Zero);
return ExpressionValue.Boolean(existed);
}, $"{touch}[name]");
registry.Register(clear, 1, 1, (context, args) =>
ExpressionValue.Boolean(context.State.Clear(
scope,
args[0].AsString(clear))), $"{clear}[name]");
registry.Register(clearAll, 0, 0, (context, _) =>
{
context.State.Clear(scope);
return ExpressionValue.One;
}, $"{clearAll}[]");
}
private static void RegisterConversionsAndMath(
ExpressionFunctionRegistry registry,
Random random)
{
RegisterUnaryMath(registry, "abs", Math.Abs);
RegisterUnaryMath(registry, "acos", Math.Acos);
RegisterUnaryMath(registry, "asin", Math.Asin);
RegisterUnaryMath(registry, "atan", Math.Atan);
RegisterUnaryMath(registry, "ceiling", Math.Ceiling);
RegisterUnaryMath(registry, "cos", Math.Cos);
RegisterUnaryMath(registry, "cosh", Math.Cosh);
RegisterUnaryMath(registry, "floor", Math.Floor);
RegisterUnaryMath(registry, "round", Math.Round);
RegisterUnaryMath(registry, "sin", Math.Sin);
RegisterUnaryMath(registry, "sinh", Math.Sinh);
RegisterUnaryMath(registry, "sqrt", Math.Sqrt);
RegisterUnaryMath(registry, "tan", Math.Tan);
RegisterUnaryMath(registry, "tanh", Math.Tanh);
registry.Register("atan2", 2, 2, (_, args) => ExpressionValue.Number(
Math.Atan2(args[0].AsNumber("atan2"), args[1].AsNumber("atan2"))),
"atan2[y,x]");
registry.Register("chr", 1, 1, (_, args) => ExpressionValue.String(
char.ConvertFromUtf32(checked((int)args[0].AsNumber("chr")))),
"chr[codepoint]");
registry.Register("ord", 1, 1, (_, args) =>
{
string value = args[0].AsString("ord");
if (value.Length == 0)
throw new ExpressionEvaluationException("ord expects a non-empty string");
return ExpressionValue.Number(char.ConvertToUtf32(value, 0));
}, "ord[text]");
registry.Register("cnumber", 1, 1, (_, args) =>
double.TryParse(
args[0].AsString("cnumber"),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out double result)
? ExpressionValue.Number(result)
: ExpressionValue.Zero,
"cnumber[text]");
registry.Register("cstr", 1, 1, (_, args) => ExpressionValue.String(
args[0].AsNumber("cstr").ToString("G15", CultureInfo.InvariantCulture)),
"cstr[number]");
registry.Register("cstrf", 2, 2, (_, args) =>
{
double number = args[0].AsNumber("cstrf");
string format = args[1].AsString("cstrf");
return ExpressionValue.String(
format.Contains('X', StringComparison.OrdinalIgnoreCase)
? checked((uint)number).ToString(format, CultureInfo.InvariantCulture)
: number.ToString(format, CultureInfo.InvariantCulture));
}, "cstrf[number,format]");
registry.Register("hexstr", 1, 1, (_, args) => ExpressionValue.String(
$"0x{checked((int)args[0].AsNumber("hexstr")):X}"),
"hexstr[number]");
registry.Register("strlen", 1, 1, (_, args) => ExpressionValue.Number(
args[0].AsString("strlen").Length), "strlen[text]");
registry.Register("tostring", 1, 1, (_, args) =>
ExpressionValue.String(args[0].ToDisplayString()), "tostring[value]");
registry.Register("istrue", 1, 1, (_, args) =>
ExpressionValue.Boolean(args[0].IsTruthy), "istrue[value]");
registry.Register("isfalse", 1, 1, (_, args) =>
ExpressionValue.Boolean(!args[0].IsTruthy), "isfalse[value]");
registry.Register("iif", 3, 3, (_, args) =>
args[0].IsTruthy ? args[1] : args[2], "iif[test,trueValue,falseValue]");
registry.Register("ifthen", 2, 3, (context, args) =>
{
string? source = args[0].IsTruthy
? args[1].AsString("ifthen")
: args.Count == 3
? args[2].AsString("ifthen")
: null;
return source is null
? ExpressionValue.Zero
: ExpressionProgram.Compile(source).Evaluate(context);
}, "ifthen[test,trueExpression,falseExpression?]");
registry.Register("randint", 2, 2, (_, args) =>
{
int minimum = checked((int)args[0].AsNumber("randint"));
int maximum = checked((int)args[1].AsNumber("randint"));
return ExpressionValue.Number(random.Next(minimum, maximum));
}, "randint[min,maxExclusive]");
registry.Register("getregexmatch", 2, 2, (_, args) =>
{
var regex = new Regex(
args[1].AsString("getregexmatch"),
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
Match match = regex.Match(args[0].AsString("getregexmatch"));
return match.Success
? ExpressionValue.String(match.Value)
: ExpressionValue.Zero;
}, "getregexmatch[text,pattern]");
}
private static void RegisterUnaryMath(
ExpressionFunctionRegistry registry,
string name,
Func<double, double> operation) =>
registry.Register(name, 1, 1, (_, args) => ExpressionValue.Number(
operation(args[0].AsNumber(name))), $"{name}[number]");
private static void RegisterLists(ExpressionFunctionRegistry registry)
{
registry.Register("listcreate", 0, int.MaxValue, (_, args) =>
ExpressionValue.List(new ExpressionList(args)), "listcreate[items...]");
registry.Register("listadd", 2, 2, (_, args) =>
{
ExpressionList list = args[0].AsList("listadd");
GuardNoCycle(list, args[1], "listadd");
list.Items.Add(args[1]);
return args[0];
}, "listadd[list,item]");
registry.Register("listinsert", 3, 3, (_, args) =>
{
ExpressionList list = args[0].AsList("listinsert");
GuardNoCycle(list, args[1], "listinsert");
int index = ToTruncatedInt(args[2], "listinsert");
if ((uint)index > (uint)list.Items.Count)
throw BadIndex("insert", index, list.Items.Count, allowEnd: true);
list.Items.Insert(index, args[1]);
return args[0];
}, "listinsert[list,item,index]");
registry.Register("listremove", 2, 2, (_, args) =>
{
args[0].AsList("listremove").Items.Remove(args[1]);
return args[0];
}, "listremove[list,item]");
registry.Register("listremoveat", 2, 2, (_, args) =>
{
ExpressionList list = args[0].AsList("listremoveat");
int index = RequireListIndex(list, args[1], "listremoveat");
list.Items.RemoveAt(index);
return args[0];
}, "listremoveat[list,index]");
registry.Register("listgetitem", 2, 2, (_, args) =>
{
ExpressionList list = args[0].AsList("listgetitem");
return list.Items[RequireListIndex(list, args[1], "listgetitem")];
}, "listgetitem[list,index]");
registry.Register("listcontains", 2, 2, (_, args) =>
ExpressionValue.Boolean(args[0].AsList("listcontains").Items.Contains(args[1])),
"listcontains[list,item]");
registry.Register("listindexof", 2, 2, (_, args) => ExpressionValue.Number(
args[0].AsList("listindexof").Items.IndexOf(args[1])),
"listindexof[list,item]");
registry.Register("listlastindexof", 2, 2, (_, args) =>
ExpressionValue.Number(args[0].AsList("listlastindexof")
.Items.LastIndexOf(args[1])), "listlastindexof[list,item]");
registry.Register("listcopy", 1, 1, (_, args) => ExpressionValue.List(
new ExpressionList(args[0].AsList("listcopy").Items)), "listcopy[list]");
registry.Register("listreverse", 1, 1, (_, args) =>
{
var values = args[0].AsList("listreverse").Items.ToArray();
Array.Reverse(values);
return ExpressionValue.List(new ExpressionList(values));
}, "listreverse[list]");
registry.Register("listpop", 1, 2, (_, args) =>
{
ExpressionList list = args[0].AsList("listpop");
int index = args.Count == 1 || args[1].AsNumber("listpop") == -1d
? list.Items.Count - 1
: RequireListIndex(list, args[1], "listpop");
if (index < 0)
throw BadIndex("pop", index, list.Items.Count, allowEnd: false);
ExpressionValue result = list.Items[index];
list.Items.RemoveAt(index);
return result;
}, "listpop[list,index?]");
registry.Register("listcount", 1, 1, (_, args) => ExpressionValue.Number(
args[0].AsList("listcount").Items.Count), "listcount[list]");
registry.Register("listclear", 1, 1, (_, args) =>
{
args[0].AsList("listclear").Items.Clear();
return args[0];
}, "listclear[list]");
registry.Register("listfilter", 2, 2, (context, args) =>
{
ExpressionList source = args[0].AsList("listfilter");
ExpressionProgram program = ExpressionProgram.Compile(
args[1].AsString("listfilter"));
var result = new ExpressionList();
WithIterationVariables(context.State, () =>
{
for (int index = 0; index < source.Items.Count; index++)
{
SetIteration(context.State, index, source.Items[index]);
if (program.Evaluate(context).IsTruthy)
result.Items.Add(source.Items[index]);
}
});
return ExpressionValue.List(result);
}, "listfilter[list,expression]");
registry.Register("listmap", 2, 2, (context, args) =>
{
ExpressionList source = args[0].AsList("listmap");
ExpressionProgram program = ExpressionProgram.Compile(
args[1].AsString("listmap"));
var result = new ExpressionList();
WithIterationVariables(context.State, () =>
{
for (int index = 0; index < source.Items.Count; index++)
{
SetIteration(context.State, index, source.Items[index]);
result.Items.Add(program.Evaluate(context));
}
});
return ExpressionValue.List(result);
}, "listmap[list,expression]");
registry.Register("listreduce", 2, 2, (context, args) =>
{
ExpressionList source = args[0].AsList("listreduce");
ExpressionProgram program = ExpressionProgram.Compile(
args[1].AsString("listreduce"));
ExpressionValue result = ExpressionValue.Zero;
WithIterationVariables(context.State, () =>
{
for (int index = 0; index < source.Items.Count; index++)
{
SetIteration(context.State, index, source.Items[index], result);
result = program.Evaluate(context);
}
});
return result;
}, "listreduce[list,expression]");
registry.Register("listsort", 1, 2, (context, args) =>
{
var result = new ExpressionList(args[0].AsList("listsort").Items);
if (args.Count == 1 || args[1].AsString("listsort").Length == 0)
{
result.Items.Sort(DefaultValueComparer.Instance);
return ExpressionValue.List(result);
}
ExpressionProgram program = ExpressionProgram.Compile(
args[1].AsString("listsort"));
WithIterationVariables(context.State, () =>
{
// A stable insertion sort avoids the exception wrapping used by
// List.Sort and lets cancellation/budget errors escape intact.
for (int index = 1; index < result.Items.Count; index++)
{
ExpressionValue value = result.Items[index];
int cursor = index - 1;
while (cursor >= 0)
{
context.State.Set(ExpressionVariableScope.Session, "1", result.Items[cursor]);
context.State.Set(ExpressionVariableScope.Session, "2", value);
if (program.Evaluate(context).AsNumber("listsort comparator") <= 0d)
break;
result.Items[cursor + 1] = result.Items[cursor];
cursor--;
}
result.Items[cursor + 1] = value;
}
});
return ExpressionValue.List(result);
}, "listsort[list,expression?]");
registry.Register("listfromrange", 2, 2, (_, args) =>
{
int start = ToTruncatedInt(args[0], "listfromrange");
int end = ToTruncatedInt(args[1], "listfromrange");
int count = checked(Math.Abs(end - start) + 1);
if (count > 100_000)
{
throw new ExpressionEvaluationException(
"listfromrange is limited to 100000 entries");
}
var result = new ExpressionList();
int step = start <= end ? 1 : -1;
for (int value = start;; value += step)
{
result.Items.Add(ExpressionValue.Number(value));
if (value == end)
break;
}
return ExpressionValue.List(result);
}, "listfromrange[start,end]");
}
private static void RegisterDictionaries(ExpressionFunctionRegistry registry)
{
registry.Register("dictcreate", 0, int.MaxValue, (_, args) =>
{
if ((args.Count & 1) != 0)
throw new ExpressionEvaluationException(
"dictcreate expects key/value pairs");
var dictionary = new ExpressionDictionary();
for (int index = 0; index < args.Count; index += 2)
{
string key = args[index].AsString("dictcreate key");
if (!dictionary.Items.TryAdd(key, args[index + 1]))
{
throw new ExpressionEvaluationException(
$"dictcreate received duplicate key '{key}'");
}
}
return ExpressionValue.Dictionary(dictionary);
}, "dictcreate[key,value,...]");
registry.Register("dictgetitem", 2, 2, (_, args) =>
{
ExpressionDictionary dictionary = args[0].AsDictionary("dictgetitem");
string key = args[1].AsString("dictgetitem key");
if (!dictionary.Items.TryGetValue(key, out ExpressionValue value))
throw new ExpressionEvaluationException($"Dictionary key '{key}' was not found");
return value;
}, "dictgetitem[dictionary,key]");
registry.Register("dictadditem", 3, 3, (_, args) =>
{
ExpressionDictionary dictionary = args[0].AsDictionary("dictadditem");
string key = args[1].AsString("dictadditem key");
GuardNoCycle(dictionary, args[2], "dictadditem");
bool replaced = dictionary.Items.ContainsKey(key);
dictionary.Items[key] = args[2];
return ExpressionValue.Boolean(replaced);
}, "dictadditem[dictionary,key,value]");
registry.Register("dicthaskey", 2, 2, (_, args) => ExpressionValue.Boolean(
args[0].AsDictionary("dicthaskey").Items.ContainsKey(
args[1].AsString("dicthaskey key"))), "dicthaskey[dictionary,key]");
registry.Register("dictremovekey", 2, 2, (_, args) => ExpressionValue.Boolean(
args[0].AsDictionary("dictremovekey").Items.Remove(
args[1].AsString("dictremovekey key"))), "dictremovekey[dictionary,key]");
registry.Register("dictkeys", 1, 1, (_, args) => ExpressionValue.List(
new ExpressionList(args[0].AsDictionary("dictkeys").Items.Keys.Select(
ExpressionValue.String))), "dictkeys[dictionary]");
registry.Register("dictvalues", 1, 1, (_, args) => ExpressionValue.List(
new ExpressionList(args[0].AsDictionary("dictvalues").Items.Values)),
"dictvalues[dictionary]");
registry.Register("dictsize", 1, 1, (_, args) => ExpressionValue.Number(
args[0].AsDictionary("dictsize").Items.Count), "dictsize[dictionary]");
registry.Register("dictclear", 1, 1, (_, args) =>
{
args[0].AsDictionary("dictclear").Items.Clear();
return args[0];
}, "dictclear[dictionary]");
registry.Register("dictcopy", 1, 1, (_, args) =>
{
var result = new ExpressionDictionary();
foreach ((string key, ExpressionValue value) in
args[0].AsDictionary("dictcopy").Items)
{
result.Items[key] = value;
}
return ExpressionValue.Dictionary(result);
}, "dictcopy[dictionary]");
}
private static void RegisterCoordinates(ExpressionFunctionRegistry registry)
{
registry.Register("coordinateparse", 1, 1, (_, args) =>
{
string source = args[0].AsString("coordinateparse");
Match match = CoordinatePattern.Match(source);
if (!match.Success)
{
throw new ExpressionEvaluationException(
$"Unable to parse coordinate '{source}'");
}
double northSouth = double.Parse(
match.Groups["ns"].Value,
CultureInfo.InvariantCulture);
double eastWest = double.Parse(
match.Groups["ew"].Value,
CultureInfo.InvariantCulture);
if (match.Groups["nsdir"].Value.Equals("S", StringComparison.OrdinalIgnoreCase))
northSouth = -Math.Abs(northSouth);
else
northSouth = Math.Abs(northSouth);
if (match.Groups["ewdir"].Value.Equals("W", StringComparison.OrdinalIgnoreCase))
eastWest = -Math.Abs(eastWest);
else
eastWest = Math.Abs(eastWest);
double elevation = match.Groups["z"].Success
? double.Parse(match.Groups["z"].Value, CultureInfo.InvariantCulture)
: 0d;
return ExpressionValue.Coordinates(new ExpressionCoordinates(
eastWest,
northSouth,
elevation));
}, "coordinateparse[text]");
registry.Register("coordinategetns", 1, 1, (_, args) =>
ExpressionValue.Number(args[0].AsCoordinates("coordinategetns").NorthSouth),
"coordinategetns[coordinates]");
registry.Register("coordinategetwe", 1, 1, (_, args) =>
ExpressionValue.Number(args[0].AsCoordinates("coordinategetwe").EastWest),
"coordinategetwe[coordinates]");
registry.Register("coordinategetz", 1, 1, (_, args) =>
ExpressionValue.Number(args[0].AsCoordinates("coordinategetz").Elevation),
"coordinategetz[coordinates]");
registry.Register("coordinatetostring", 1, 1, (_, args) =>
ExpressionValue.String(args[0].AsCoordinates("coordinatetostring").ToString()),
"coordinatetostring[coordinates]");
registry.Register("coordinatedistanceflat", 2, 2, (_, args) =>
ExpressionValue.Number(CoordinateDistance(args[0], args[1], includeElevation: false)),
"coordinatedistanceflat[first,second]");
registry.Register("coordinatedistancewithz", 2, 2, (_, args) =>
ExpressionValue.Number(CoordinateDistance(args[0], args[1], includeElevation: true)),
"coordinatedistancewithz[first,second]");
}
private static void RegisterTime(ExpressionFunctionRegistry registry)
{
registry.Register("getdatetimelocal", 0, 1, (_, args) => ExpressionValue.String(
DateTime.Now.ToString(
args.Count == 0 ? "hh:mm:ss tt" : args[0].AsString("getdatetimelocal"),
CultureInfo.InvariantCulture)), "getdatetimelocal[format?]");
registry.Register("getdatetimeutc", 0, 1, (_, args) => ExpressionValue.String(
DateTime.UtcNow.ToString(
args.Count == 0 ? "hh:mm:ss tt" : args[0].AsString("getdatetimeutc"),
CultureInfo.InvariantCulture)), "getdatetimeutc[format?]");
registry.Register("getunixtime", 0, 0, (_, _) => ExpressionValue.Number(
DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() / 1000d), "getunixtime[]");
registry.Register("stopwatchcreate", 0, 0, (_, _) =>
ExpressionValue.Stopwatch(new ExpressionStopwatch()), "stopwatchcreate[]");
registry.Register("stopwatchstart", 1, 1, (_, args) =>
{
args[0].AsStopwatch("stopwatchstart").Start();
return args[0];
}, "stopwatchstart[stopwatch]");
registry.Register("stopwatchstop", 1, 1, (_, args) =>
{
args[0].AsStopwatch("stopwatchstop").Stop();
return args[0];
}, "stopwatchstop[stopwatch]");
registry.Register("stopwatchelapsedseconds", 1, 1, (_, args) =>
ExpressionValue.Number(args[0].AsStopwatch(
"stopwatchelapsedseconds").ElapsedSeconds),
"stopwatchelapsedseconds[stopwatch]");
}
private static double CoordinateDistance(
in ExpressionValue first,
in ExpressionValue second,
bool includeElevation)
{
ExpressionCoordinates left = first.AsCoordinates("coordinate distance");
ExpressionCoordinates right = second.AsCoordinates("coordinate distance");
double eastWest = (left.EastWest - right.EastWest) * 240d;
double northSouth = (left.NorthSouth - right.NorthSouth) * 240d;
double elevation = includeElevation
? (left.Elevation - right.Elevation) * 240d
: 0d;
return Math.Sqrt(
eastWest * eastWest
+ northSouth * northSouth
+ elevation * elevation);
}
private static int RequireListIndex(
ExpressionList list,
in ExpressionValue value,
string operation)
{
int index = ToTruncatedInt(value, operation);
if ((uint)index >= (uint)list.Items.Count)
throw BadIndex(operation, index, list.Items.Count, allowEnd: false);
return index;
}
private static int ToTruncatedInt(in ExpressionValue value, string operation) =>
checked((int)value.AsNumber(operation));
private static ExpressionEvaluationException BadIndex(
string operation,
int index,
int count,
bool allowEnd) => new(
$"Unable to {operation} index {index}; valid range is 0.."
+ (allowEnd ? count : count - 1));
private static void SetIteration(
ExpressionState state,
int index,
in ExpressionValue item,
ExpressionValue? accumulator = null)
{
state.Set(ExpressionVariableScope.Session, "0", ExpressionValue.Number(index));
state.Set(ExpressionVariableScope.Session, "1", item);
if (accumulator is { } value)
state.Set(ExpressionVariableScope.Session, "2", value);
}
private static void WithIterationVariables(ExpressionState state, Action action)
{
var saved = new (string Name, bool Exists, ExpressionValue Value)[3];
for (int index = 0; index < saved.Length; index++)
{
string name = index.ToString(CultureInfo.InvariantCulture);
saved[index] = (
name,
state.Contains(ExpressionVariableScope.Session, name),
state.Get(ExpressionVariableScope.Session, name));
}
try
{
action();
}
finally
{
foreach ((string name, bool exists, ExpressionValue value) in saved)
{
if (exists)
state.Set(ExpressionVariableScope.Session, name, value);
else
state.Clear(ExpressionVariableScope.Session, name);
}
}
}
private static void GuardNoCycle(object destination, in ExpressionValue value, string operation)
{
if (ContainsReference(value, destination, new HashSet<object>(
ReferenceEqualityComparer.Instance)))
{
throw new ExpressionEvaluationException(
$"{operation} cannot create a cyclic collection");
}
}
private static bool ContainsReference(
in ExpressionValue value,
object destination,
HashSet<object> visited)
{
if (value.Kind == ExpressionValueKind.List)
{
ExpressionList list = value.AsList();
if (ReferenceEquals(list, destination))
return true;
return visited.Add(list)
&& list.Items.Any(item => ContainsReference(item, destination, visited));
}
if (value.Kind == ExpressionValueKind.Dictionary)
{
ExpressionDictionary dictionary = value.AsDictionary();
if (ReferenceEquals(dictionary, destination))
return true;
return visited.Add(dictionary)
&& dictionary.Items.Values.Any(item =>
ContainsReference(item, destination, visited));
}
return false;
}
private sealed class DefaultValueComparer : IComparer<ExpressionValue>
{
public static DefaultValueComparer Instance { get; } = new();
public int Compare(ExpressionValue left, ExpressionValue right)
{
if (left.Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean
&& right.Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean)
{
return left.AsNumber().CompareTo(right.AsNumber());
}
if (left.Kind == ExpressionValueKind.String
&& right.Kind == ExpressionValueKind.String)
{
return StringComparer.OrdinalIgnoreCase.Compare(
left.AsString(),
right.AsString());
}
int kind = left.Kind.CompareTo(right.Kind);
return kind != 0
? kind
: StringComparer.Ordinal.Compare(
left.ToDisplayString(),
right.ToDisplayString());
}
}
}

View file

@ -0,0 +1,84 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Expressions;
/// <summary>UtilityBelt-compatible session XP/luminance accumulator.</summary>
internal sealed class ExperienceMeter(IPluginHost host)
{
private long _lastExperience;
private long _lastLuminance;
private bool _hasBaseline;
public double DurationSeconds { get; private set; }
public long Experience { get; private set; }
public long Luminance { get; private set; }
public double ExperiencePerHour => DurationSeconds > 0d
? Experience / DurationSeconds * 3600d
: 0d;
public double LuminancePerHour => DurationSeconds > 0d
? Luminance / DurationSeconds * 3600d
: 0d;
public void OnTick(double elapsedSeconds)
{
if (!host.Automation.Character.IsInWorld
|| !host.Automation.Objects.TryCaptureProperties(
host.Automation.Character.ObjectId,
out PluginItemProperties properties))
{
_hasBaseline = false;
return;
}
long experience = properties.Int64s.TryGetValue(1u, out long xp) ? xp : 0L;
long luminance = properties.Int64s.TryGetValue(6u, out long lum) ? lum : 0L;
if (!_hasBaseline)
{
_lastExperience = experience;
_lastLuminance = luminance;
_hasBaseline = true;
}
else
{
if (experience >= _lastExperience)
Experience = checked(Experience + experience - _lastExperience);
if (luminance >= _lastLuminance)
Luminance = checked(Luminance + luminance - _lastLuminance);
_lastExperience = experience;
_lastLuminance = luminance;
}
DurationSeconds += elapsedSeconds;
}
public void Reset()
{
DurationSeconds = 0d;
Experience = 0L;
Luminance = 0L;
_hasBaseline = false;
}
public string Format()
{
string result = Experience.ToString("N0", CultureInfo.InvariantCulture)
+ " XP";
if (Luminance != 0)
{
result += " and "
+ Luminance.ToString("N0", CultureInfo.InvariantCulture)
+ " LUM";
}
result += ", "
+ DurationSeconds.ToString("N0", CultureInfo.InvariantCulture)
+ "s, "
+ ExperiencePerHour.ToString("N0", CultureInfo.InvariantCulture)
+ " XP/hr";
if (Luminance != 0)
{
result += " and "
+ LuminancePerHour.ToString("N0", CultureInfo.InvariantCulture)
+ " LUM/hr";
}
return result;
}
}

View file

@ -0,0 +1,789 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace AcDream.Plugins.MossTank.Expressions;
internal sealed class ExpressionProgram
{
private readonly Node[] _statements;
private ExpressionProgram(Node[] statements) => _statements = statements;
public static ExpressionProgram Compile(string source)
{
if (string.IsNullOrWhiteSpace(source))
throw new ExpressionParseException("Expression is empty", 0);
return new ExpressionProgram(new Parser(source).ParseProgram());
}
public ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
ArgumentNullException.ThrowIfNull(context);
ExpressionValue result = ExpressionValue.Zero;
foreach (Node statement in _statements)
result = statement.Evaluate(context);
return result;
}
private abstract class Node(int offset)
{
protected int Offset { get; } = offset;
internal abstract ExpressionValue Evaluate(ExpressionEvaluationContext context);
}
private sealed class LiteralNode(ExpressionValue value, int offset) : Node(offset)
{
internal override ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
context.Step(Offset);
return value;
}
}
private sealed class VariableNode(
ExpressionVariableScope scope,
Node name,
int offset) : Node(offset)
{
public ExpressionVariableScope Scope { get; } = scope;
internal override ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
context.Step(Offset);
return context.State.Get(Scope, ResolveName(context));
}
public ExpressionValue Set(
ExpressionEvaluationContext context,
ExpressionValue value) =>
context.State.Set(Scope, ResolveName(context), value);
private string ResolveName(ExpressionEvaluationContext context) =>
name.Evaluate(context).ToDisplayString();
}
private sealed class AssignmentNode(
VariableNode variable,
Node value,
int offset) : Node(offset)
{
internal override ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
context.Step(Offset);
ExpressionValue result = value.Evaluate(context);
return variable.Set(context, result);
}
}
private sealed class FunctionNode(
string name,
Node[] arguments,
int offset) : Node(offset)
{
internal override ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
var values = new ExpressionValue[arguments.Length];
for (int index = 0; index < arguments.Length; index++)
values[index] = arguments[index].Evaluate(context);
return context.Invoke(name, values, Offset);
}
}
private sealed class UnaryNode(TokenKind operation, Node operand, int offset)
: Node(offset)
{
internal override ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
context.Step(Offset);
ExpressionValue value = operand.Evaluate(context);
return operation switch
{
TokenKind.Minus => ExpressionValue.Number(
-value.AsNumber("unary '-'")),
TokenKind.Tilde => ExpressionValue.Number(
~value.AsInt32("bitwise complement")),
_ => throw new ExpressionEvaluationException(
$"Unsupported unary operator {operation}", Offset),
};
}
}
private sealed class BinaryNode(
TokenKind operation,
Node left,
Node right,
int offset) : Node(offset)
{
internal override ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
context.Step(Offset);
ExpressionValue lhs = left.Evaluate(context);
if (operation == TokenKind.AndAnd)
return lhs.IsTruthy ? right.Evaluate(context) : ExpressionValue.Zero;
if (operation == TokenKind.OrOr)
return lhs.IsTruthy ? lhs : right.Evaluate(context);
ExpressionValue rhs = right.Evaluate(context);
return operation switch
{
TokenKind.Plus => Add(lhs, rhs),
TokenKind.Minus => Subtract(lhs, rhs),
TokenKind.Star => ExpressionValue.Number(
lhs.AsNumber("multiplication") * rhs.AsNumber("multiplication")),
TokenKind.Slash => ExpressionValue.Number(
lhs.AsNumber("division") / rhs.AsNumber("division")),
TokenKind.Percent => ExpressionValue.Number(
lhs.AsNumber("modulo") % rhs.AsNumber("modulo")),
TokenKind.Caret => ExpressionValue.Number(Math.Pow(
lhs.AsNumber("power"), rhs.AsNumber("power"))),
TokenKind.ShiftLeft => ExpressionValue.Number(
lhs.AsInt32("left shift") << rhs.AsInt32("left shift")),
TokenKind.ShiftRight => ExpressionValue.Number(
lhs.AsInt32("right shift") >> rhs.AsInt32("right shift")),
TokenKind.Ampersand => ExpressionValue.Number(
lhs.AsInt32("bitwise and") & rhs.AsInt32("bitwise and")),
TokenKind.Pipe => ExpressionValue.Number(
lhs.AsInt32("bitwise or") | rhs.AsInt32("bitwise or")),
TokenKind.Hash => RegexMatch(context, lhs, rhs),
TokenKind.EqualEqual => ExpressionValue.Boolean(lhs.Equals(rhs)),
TokenKind.BangEqual => ExpressionValue.Boolean(!lhs.Equals(rhs)),
TokenKind.Less => Compare(lhs, rhs, static comparison => comparison < 0),
TokenKind.LessEqual => Compare(lhs, rhs, static comparison => comparison <= 0),
TokenKind.Greater => Compare(lhs, rhs, static comparison => comparison > 0),
TokenKind.GreaterEqual => Compare(lhs, rhs, static comparison => comparison >= 0),
_ => throw new ExpressionEvaluationException(
$"Unsupported binary operator {operation}", Offset),
};
}
private static ExpressionValue Add(
in ExpressionValue left,
in ExpressionValue right)
{
if (left.Kind == ExpressionValueKind.Number
|| left.Kind == ExpressionValueKind.Boolean)
{
return ExpressionValue.Number(
left.AsNumber("addition") + right.AsNumber("addition"));
}
if (left.Kind == ExpressionValueKind.String)
{
return ExpressionValue.String(
left.AsString("concatenation") + right.ToDisplayString());
}
throw new ExpressionEvaluationException(
$"Unable to add {left.Kind} to {right.Kind}.");
}
private static ExpressionValue Subtract(
in ExpressionValue left,
in ExpressionValue right)
{
if (left.Kind is ExpressionValueKind.Number
or ExpressionValueKind.Boolean
&& right.Kind is ExpressionValueKind.Number
or ExpressionValueKind.Boolean)
{
return ExpressionValue.Number(
left.AsNumber("subtraction") - right.AsNumber("subtraction"));
}
if (left.Kind == ExpressionValueKind.String
&& right.Kind == ExpressionValueKind.String)
{
return ExpressionValue.String(
left.AsString() + "-" + right.AsString());
}
throw new ExpressionEvaluationException(
$"Unable to subtract {right.Kind} from {left.Kind}.");
}
private static ExpressionValue Compare(
in ExpressionValue left,
in ExpressionValue right,
Func<int, bool> predicate)
{
double lhs = left.AsNumber("comparison");
double rhs = right.AsNumber("comparison");
return ExpressionValue.Boolean(predicate(lhs.CompareTo(rhs)));
}
private static ExpressionValue RegexMatch(
ExpressionEvaluationContext context,
in ExpressionValue left,
in ExpressionValue right)
{
var regex = new Regex(
right.ToDisplayString(),
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
Match match = regex.Match(left.ToDisplayString());
foreach (string groupName in regex.GetGroupNames())
{
string variableName = "capturegroup_" + groupName;
Group group = match.Groups[groupName];
if (group.Success)
{
context.State.Set(
ExpressionVariableScope.Session,
variableName,
ExpressionValue.String(group.Value));
}
else
{
context.State.Clear(
ExpressionVariableScope.Session,
variableName);
}
}
return ExpressionValue.Boolean(match.Success);
}
}
private sealed class IndexNode(
Node source,
Node? start,
Node? end,
bool isSlice,
int offset) : Node(offset)
{
internal override ExpressionValue Evaluate(ExpressionEvaluationContext context)
{
context.Step(Offset);
ExpressionValue value = source.Evaluate(context);
if (value.Kind == ExpressionValueKind.Dictionary)
{
if (isSlice)
{
throw new ExpressionEvaluationException(
"Range indices are not supported with dictionaries",
Offset);
}
string key = (start?.Evaluate(context) ?? ExpressionValue.Zero)
.AsString("dictionary index");
return value.AsDictionary().Items.TryGetValue(
key,
out ExpressionValue found)
? found
: ExpressionValue.Zero;
}
int length = value.Kind switch
{
ExpressionValueKind.List => value.AsList().Items.Count,
ExpressionValueKind.String => value.AsString().Length,
_ => throw new ExpressionEvaluationException(
$"{value.Kind} does not support index access",
Offset),
};
int first = ResolveIndex(context, start, length, 0, allowEnd: isSlice);
if (!isSlice)
{
return value.Kind == ExpressionValueKind.List
? value.AsList().Items[first]
: ExpressionValue.String(value.AsString().Substring(first, 1));
}
int last = ResolveIndex(context, end, length, length, allowEnd: true);
int count = Math.Max(0, last - first);
return value.Kind == ExpressionValueKind.List
? ExpressionValue.List(new ExpressionList(
value.AsList().Items.Skip(first).Take(count)))
: ExpressionValue.String(value.AsString().Substring(first, count));
}
private int ResolveIndex(
ExpressionEvaluationContext context,
Node? expression,
int length,
int defaultValue,
bool allowEnd)
{
int index = expression is null
? defaultValue
: expression.Evaluate(context).AsInt32("index");
if (index < 0)
index += length;
int maximum = allowEnd ? length : length - 1;
if (index < 0 || index > maximum)
{
throw new ExpressionEvaluationException(
$"Index {index} is outside 0..{maximum}",
Offset);
}
return index;
}
}
private enum TokenKind
{
End,
Number,
HexNumber,
String,
True,
False,
LeftParen,
RightParen,
LeftBracket,
RightBracket,
LeftBrace,
RightBrace,
Comma,
Semicolon,
Colon,
Dollar,
At,
Ampersand,
Pipe,
Tilde,
Plus,
Minus,
Star,
Slash,
Percent,
Caret,
Hash,
Equal,
EqualEqual,
BangEqual,
Less,
LessEqual,
Greater,
GreaterEqual,
ShiftLeft,
ShiftRight,
AndAnd,
OrOr,
}
private readonly record struct Token(TokenKind Kind, string Text, int Offset);
private sealed class Lexer(string source)
{
private int _offset;
public Token Next()
{
while (_offset < source.Length && char.IsWhiteSpace(source[_offset]))
_offset++;
if (_offset >= source.Length)
return new Token(TokenKind.End, string.Empty, _offset);
int start = _offset;
char current = source[_offset];
if (current is '`' or '\'' or '"')
return ReadQuoted(current, start);
if (char.IsDigit(current)
|| (current == '.'
&& _offset + 1 < source.Length
&& char.IsDigit(source[_offset + 1])))
{
return ReadNumber(start);
}
if (TryOperator(out Token operation))
return operation;
while (_offset < source.Length && !IsDelimiter(source[_offset]))
_offset++;
string text = source[start.._offset].Trim();
if (text.Length == 0)
{
throw new ExpressionParseException(
$"Unexpected character '{source[start]}'",
start);
}
return text.Equals("true", StringComparison.OrdinalIgnoreCase)
? new Token(TokenKind.True, text, start)
: text.Equals("false", StringComparison.OrdinalIgnoreCase)
? new Token(TokenKind.False, text, start)
: new Token(TokenKind.String, Unescape(text), start);
}
private Token ReadQuoted(char delimiter, int start)
{
_offset++;
var built = new StringBuilder();
while (_offset < source.Length)
{
char value = source[_offset++];
if (value == delimiter)
return new Token(TokenKind.String, built.ToString(), start);
if (value == '\\' && _offset < source.Length)
value = source[_offset++];
built.Append(value);
}
throw new ExpressionParseException("Unterminated string", start);
}
private Token ReadNumber(int start)
{
if (_offset + 1 < source.Length
&& source[_offset] == '0'
&& source[_offset + 1] is 'x' or 'X')
{
_offset += 2;
int digits = _offset;
while (_offset < source.Length && Uri.IsHexDigit(source[_offset]))
_offset++;
if (_offset == digits)
throw new ExpressionParseException("Hexadecimal digits expected", start);
return new Token(TokenKind.HexNumber, source[digits.._offset], start);
}
bool dot = false;
while (_offset < source.Length)
{
char value = source[_offset];
if (char.IsDigit(value))
{
_offset++;
continue;
}
if (value == '.' && !dot)
{
dot = true;
_offset++;
continue;
}
break;
}
return new Token(TokenKind.Number, source[start.._offset], start);
}
private bool TryOperator(out Token token)
{
int start = _offset;
if (_offset + 1 < source.Length)
{
string pair = source.Substring(_offset, 2);
TokenKind pairKind = pair switch
{
"==" => TokenKind.EqualEqual,
"!=" => TokenKind.BangEqual,
"<=" => TokenKind.LessEqual,
">=" => TokenKind.GreaterEqual,
"<<" => TokenKind.ShiftLeft,
">>" => TokenKind.ShiftRight,
"&&" => TokenKind.AndAnd,
"||" => TokenKind.OrOr,
_ => TokenKind.End,
};
if (pairKind != TokenKind.End)
{
_offset += 2;
token = new Token(pairKind, pair, start);
return true;
}
}
TokenKind kind = source[_offset] switch
{
'(' => TokenKind.LeftParen,
')' => TokenKind.RightParen,
'[' => TokenKind.LeftBracket,
']' => TokenKind.RightBracket,
'{' => TokenKind.LeftBrace,
'}' => TokenKind.RightBrace,
',' => TokenKind.Comma,
';' => TokenKind.Semicolon,
':' => TokenKind.Colon,
'$' => TokenKind.Dollar,
'@' => TokenKind.At,
'&' => TokenKind.Ampersand,
'|' => TokenKind.Pipe,
'~' => TokenKind.Tilde,
'+' => TokenKind.Plus,
'-' => TokenKind.Minus,
'*' => TokenKind.Star,
'/' => TokenKind.Slash,
'%' => TokenKind.Percent,
'^' => TokenKind.Caret,
'#' => TokenKind.Hash,
'=' => TokenKind.Equal,
'<' => TokenKind.Less,
'>' => TokenKind.Greater,
_ => TokenKind.End,
};
if (kind == TokenKind.End)
{
token = default;
return false;
}
_offset++;
token = new Token(kind, source[start].ToString(), start);
return true;
}
private static bool IsDelimiter(char value) =>
char.IsWhiteSpace(value)
? false
: value is '(' or ')' or '[' or ']' or '{' or '}'
or ',' or ';' or ':' or '$' or '@' or '&' or '|'
or '~' or '+' or '-' or '*' or '/' or '%' or '^'
or '#' or '=' or '!' or '<' or '>' or '`' or '\'' or '"';
private static string Unescape(string value)
{
if (!value.Contains('\\', StringComparison.Ordinal))
return value;
var built = new StringBuilder(value.Length);
for (int index = 0; index < value.Length; index++)
{
char current = value[index];
if (current == '\\' && index + 1 < value.Length)
current = value[++index];
built.Append(current);
}
return built.ToString();
}
}
private sealed class Parser
{
private readonly Lexer _lexer;
private Token _current;
public Parser(string source)
{
_lexer = new Lexer(source);
_current = _lexer.Next();
}
public Node[] ParseProgram()
{
var statements = new List<Node>();
while (_current.Kind != TokenKind.End)
{
statements.Add(ParseAssignment());
if (_current.Kind == TokenKind.Semicolon)
{
Advance();
continue;
}
if (_current.Kind != TokenKind.End)
{
throw new ExpressionParseException(
$"Unexpected token '{_current.Text}'",
_current.Offset);
}
}
return statements.ToArray();
}
private Node ParseAssignment()
{
Node left = ParseOr();
if (_current.Kind != TokenKind.Equal)
return left;
Token operation = _current;
Advance();
if (left is not VariableNode variable)
{
throw new ExpressionParseException(
"Only variables may appear on the left of '='",
operation.Offset);
}
return new AssignmentNode(variable, ParseAssignment(), operation.Offset);
}
private Node ParseOr() => ParseLeft(ParseAnd, TokenKind.OrOr);
private Node ParseAnd() => ParseLeft(ParseComparison, TokenKind.AndAnd);
private Node ParseComparison() => ParseLeft(
ParseRegex,
TokenKind.EqualEqual,
TokenKind.BangEqual,
TokenKind.Less,
TokenKind.LessEqual,
TokenKind.Greater,
TokenKind.GreaterEqual);
private Node ParseRegex() => ParseLeft(ParseBitwiseOr, TokenKind.Hash);
private Node ParseBitwiseOr() => ParseLeft(ParseBitwiseAnd, TokenKind.Pipe);
private Node ParseBitwiseAnd() => ParseLeft(ParseShift, TokenKind.Ampersand);
private Node ParseShift() => ParseLeft(
ParseAdditive,
TokenKind.ShiftLeft,
TokenKind.ShiftRight);
private Node ParseAdditive() => ParseLeft(
ParseMultiplicative,
TokenKind.Plus,
TokenKind.Minus);
private Node ParseMultiplicative() => ParseLeft(
ParsePower,
TokenKind.Star,
TokenKind.Slash,
TokenKind.Percent);
private Node ParsePower()
{
Node left = ParseUnary();
if (_current.Kind != TokenKind.Caret)
return left;
Token operation = _current;
Advance();
return new BinaryNode(
operation.Kind,
left,
ParsePower(),
operation.Offset);
}
private Node ParseUnary()
{
if (_current.Kind is not (TokenKind.Minus or TokenKind.Tilde))
return ParsePostfix();
Token operation = _current;
Advance();
return new UnaryNode(operation.Kind, ParseUnary(), operation.Offset);
}
private Node ParsePostfix()
{
Node source = ParsePrimary();
while (_current.Kind == TokenKind.LeftBrace)
{
Token opening = _current;
Advance();
Node? start = null;
Node? end = null;
bool slice = false;
if (_current.Kind != TokenKind.Colon
&& _current.Kind != TokenKind.RightBrace)
{
start = ParseAssignment();
}
if (_current.Kind == TokenKind.Colon)
{
slice = true;
Advance();
if (_current.Kind != TokenKind.RightBrace)
end = ParseAssignment();
}
Require(TokenKind.RightBrace, "Closing '}' expected");
source = new IndexNode(source, start, end, slice, opening.Offset);
}
return source;
}
private Node ParsePrimary()
{
Token token = _current;
switch (token.Kind)
{
case TokenKind.Number:
Advance();
return new LiteralNode(
ExpressionValue.Number(double.Parse(
token.Text,
NumberStyles.Float,
CultureInfo.InvariantCulture)),
token.Offset);
case TokenKind.HexNumber:
Advance();
return new LiteralNode(
ExpressionValue.Number(Convert.ToUInt32(
token.Text,
16)),
token.Offset);
case TokenKind.True:
case TokenKind.False:
Advance();
return new LiteralNode(
ExpressionValue.Boolean(token.Kind == TokenKind.True),
token.Offset);
case TokenKind.String:
Advance();
if (_current.Kind != TokenKind.LeftBracket)
{
return new LiteralNode(
ExpressionValue.String(token.Text),
token.Offset);
}
return ParseFunction(token);
case TokenKind.Dollar:
case TokenKind.At:
case TokenKind.Ampersand:
return ParseVariable();
case TokenKind.LeftParen:
Advance();
Node nested = ParseAssignment();
Require(TokenKind.RightParen, "Closing ')' expected");
return nested;
default:
throw new ExpressionParseException(
$"Expression expected; found '{token.Text}'",
token.Offset);
}
}
private Node ParseFunction(Token name)
{
Require(TokenKind.LeftBracket, "Opening '[' expected");
var arguments = new List<Node>();
if (_current.Kind != TokenKind.RightBracket)
{
while (true)
{
arguments.Add(ParseAssignment());
if (_current.Kind != TokenKind.Comma)
break;
Advance();
}
}
Require(TokenKind.RightBracket, "Closing ']' expected");
return new FunctionNode(name.Text, arguments.ToArray(), name.Offset);
}
private Node ParseVariable()
{
Token prefix = _current;
Advance();
if (_current.Kind is TokenKind.End
or TokenKind.Comma
or TokenKind.Semicolon
or TokenKind.RightBracket
or TokenKind.RightBrace
or TokenKind.RightParen)
{
throw new ExpressionParseException(
"Variable name expected",
_current.Offset);
}
Node name = ParsePrimary();
ExpressionVariableScope scope = prefix.Kind switch
{
TokenKind.Dollar => ExpressionVariableScope.Session,
TokenKind.At => ExpressionVariableScope.Persistent,
TokenKind.Ampersand => ExpressionVariableScope.Global,
_ => throw new InvalidOperationException(),
};
return new VariableNode(scope, name, prefix.Offset);
}
private Node ParseLeft(
Func<Node> operand,
params TokenKind[] operations)
{
Node left = operand();
while (operations.Contains(_current.Kind))
{
Token operation = _current;
Advance();
left = new BinaryNode(
operation.Kind,
left,
operand(),
operation.Offset);
}
return left;
}
private void Require(TokenKind expected, string message)
{
if (_current.Kind != expected)
throw new ExpressionParseException(message, _current.Offset);
Advance();
}
private void Advance() => _current = _lexer.Next();
}
}

View file

@ -0,0 +1,204 @@
namespace AcDream.Plugins.MossTank.Expressions;
internal enum ExpressionVariableScope
{
Session,
Persistent,
Global,
}
internal sealed class ExpressionState
{
private readonly Dictionary<string, ExpressionValue> _session =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ExpressionValue> _persistent =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ExpressionValue> _global =
new(StringComparer.OrdinalIgnoreCase);
public ExpressionValue Get(ExpressionVariableScope scope, string name) =>
Table(scope).TryGetValue(name, out ExpressionValue value)
? value
: ExpressionValue.Zero;
public bool Contains(ExpressionVariableScope scope, string name) =>
Table(scope).ContainsKey(name);
public ExpressionValue Set(
ExpressionVariableScope scope,
string name,
ExpressionValue value)
{
Table(scope)[name] = value;
return value;
}
public bool Clear(ExpressionVariableScope scope, string name) =>
Table(scope).Remove(name);
public void Clear(ExpressionVariableScope scope) => Table(scope).Clear();
public IReadOnlyDictionary<string, ExpressionValue> Capture(
ExpressionVariableScope scope) =>
new Dictionary<string, ExpressionValue>(Table(scope),
StringComparer.OrdinalIgnoreCase);
public void Replace(
ExpressionVariableScope scope,
IEnumerable<KeyValuePair<string, ExpressionValue>> values)
{
Dictionary<string, ExpressionValue> target = Table(scope);
target.Clear();
foreach ((string name, ExpressionValue value) in values)
target[name] = value;
}
private Dictionary<string, ExpressionValue> Table(
ExpressionVariableScope scope) => scope switch
{
ExpressionVariableScope.Session => _session,
ExpressionVariableScope.Persistent => _persistent,
ExpressionVariableScope.Global => _global,
_ => throw new ArgumentOutOfRangeException(nameof(scope)),
};
}
internal delegate ExpressionValue ExpressionFunctionHandler(
ExpressionEvaluationContext context,
IReadOnlyList<ExpressionValue> arguments);
internal sealed record ExpressionFunction(
string Name,
int MinimumArguments,
int MaximumArguments,
ExpressionFunctionHandler Handler,
string Signature,
string Description);
internal sealed class ExpressionFunctionRegistry
{
private readonly Dictionary<string, ExpressionFunction> _functions =
new(StringComparer.OrdinalIgnoreCase);
public IReadOnlyCollection<ExpressionFunction> Functions =>
_functions.Values;
public void Register(
string name,
int minimumArguments,
int maximumArguments,
ExpressionFunctionHandler handler,
string? signature = null,
string description = "")
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(handler);
if (minimumArguments < 0 || maximumArguments < minimumArguments)
throw new ArgumentOutOfRangeException(nameof(minimumArguments));
var function = new ExpressionFunction(
name,
minimumArguments,
maximumArguments,
handler,
signature ?? name + "[...]",
description);
if (!_functions.TryAdd(name, function))
{
throw new InvalidOperationException(
$"Expression function '{name}' is already registered.");
}
}
public void Alias(string alias, string existing)
{
if (!_functions.TryGetValue(existing, out ExpressionFunction? function))
throw new InvalidOperationException(
$"Expression function '{existing}' is not registered.");
Register(
alias,
function.MinimumArguments,
function.MaximumArguments,
function.Handler,
function.Signature.Replace(existing, alias, StringComparison.Ordinal),
function.Description);
}
public ExpressionFunction Resolve(string name, int offset)
{
if (_functions.TryGetValue(name, out ExpressionFunction? function))
return function;
throw new ExpressionEvaluationException(
$"Unknown expression method: {name}",
offset);
}
}
internal sealed class ExpressionEvaluationContext
{
private int _remainingInstructions;
public ExpressionEvaluationContext(
ExpressionState state,
ExpressionFunctionRegistry functions,
int instructionBudget = 10_000,
CancellationToken cancellationToken = default)
{
State = state ?? throw new ArgumentNullException(nameof(state));
Functions = functions ?? throw new ArgumentNullException(nameof(functions));
if (instructionBudget <= 0)
throw new ArgumentOutOfRangeException(nameof(instructionBudget));
_remainingInstructions = instructionBudget;
CancellationToken = cancellationToken;
}
public ExpressionState State { get; }
public ExpressionFunctionRegistry Functions { get; }
public CancellationToken CancellationToken { get; }
public int RemainingInstructions => _remainingInstructions;
public void Step(int offset)
{
CancellationToken.ThrowIfCancellationRequested();
if (--_remainingInstructions < 0)
{
throw new ExpressionEvaluationException(
"Expression instruction budget exceeded",
offset);
}
}
public ExpressionValue Invoke(
string name,
IReadOnlyList<ExpressionValue> arguments,
int offset)
{
Step(offset);
ExpressionFunction function = Functions.Resolve(name, offset);
if (arguments.Count < function.MinimumArguments
|| arguments.Count > function.MaximumArguments)
{
string expected = function.MinimumArguments == function.MaximumArguments
? function.MinimumArguments.ToString(
System.Globalization.CultureInfo.InvariantCulture)
: $"{function.MinimumArguments}..{function.MaximumArguments}";
throw new ExpressionEvaluationException(
$"{function.Signature} expects {expected} arguments; "
+ $"{arguments.Count} were passed",
offset);
}
try
{
return function.Handler(this, arguments);
}
catch (ExpressionEvaluationException)
{
throw;
}
catch (Exception error)
{
throw new ExpressionEvaluationException(
$"{function.Signature} failed: {error.Message}",
offset);
}
}
}

View file

@ -0,0 +1,240 @@
using System.Globalization;
namespace AcDream.Plugins.MossTank.Expressions;
internal enum ExpressionValueKind
{
Number,
String,
Boolean,
List,
Dictionary,
Coordinates,
WorldObject,
Stopwatch,
UiControl,
}
internal readonly record struct ExpressionCoordinates(
double EastWest,
double NorthSouth,
double Elevation = 0d)
{
public override string ToString()
{
string ns = NorthSouth < 0d ? "S" : "N";
string ew = EastWest < 0d ? "W" : "E";
return string.Create(
CultureInfo.InvariantCulture,
$"{Math.Abs(NorthSouth):0.0}{ns}, {Math.Abs(EastWest):0.0}{ew}");
}
}
internal sealed class ExpressionList
{
public List<ExpressionValue> Items { get; } = [];
public ExpressionList()
{
}
public ExpressionList(IEnumerable<ExpressionValue> values) =>
Items.AddRange(values);
public override string ToString() =>
$"[{string.Join(",", Items.Select(static item => item.ToDisplayString()))}]";
}
internal sealed class ExpressionDictionary
{
public Dictionary<string, ExpressionValue> Items { get; } =
new(StringComparer.Ordinal);
public override string ToString() =>
$"[{string.Join(",", Items.Select(static pair =>
pair.Key + "=>" + pair.Value.ToDisplayString()))}]";
}
internal sealed class ExpressionStopwatch
{
private readonly System.Diagnostics.Stopwatch _clock = new();
public bool IsRunning => _clock.IsRunning;
public double ElapsedSeconds => _clock.Elapsed.TotalSeconds;
public void Start() => _clock.Start();
public void Stop() => _clock.Stop();
public void Reset() => _clock.Reset();
public void Restart() => _clock.Restart();
public override string ToString() =>
ElapsedSeconds.ToString("0.###", CultureInfo.InvariantCulture);
}
internal readonly record struct ExpressionWorldObject(uint ObjectId);
internal readonly record struct ExpressionUiControl(string View, string Control);
internal readonly struct ExpressionValue : IEquatable<ExpressionValue>
{
private readonly double _number;
private readonly object? _reference;
private ExpressionValue(
ExpressionValueKind kind,
double number,
object? reference)
{
Kind = kind;
_number = number;
_reference = reference;
}
public ExpressionValueKind Kind { get; }
public static ExpressionValue Zero => Number(0d);
public static ExpressionValue One => Number(1d);
public static ExpressionValue Number(double value) =>
new(ExpressionValueKind.Number, value, null);
public static ExpressionValue String(string? value) =>
new(ExpressionValueKind.String, 0d, value ?? string.Empty);
public static ExpressionValue Boolean(bool value) =>
new(ExpressionValueKind.Boolean, value ? 1d : 0d, null);
public static ExpressionValue List(ExpressionList value) =>
new(ExpressionValueKind.List, 0d, value);
public static ExpressionValue Dictionary(ExpressionDictionary value) =>
new(ExpressionValueKind.Dictionary, 0d, value);
public static ExpressionValue Coordinates(ExpressionCoordinates value) =>
new(ExpressionValueKind.Coordinates, 0d, value);
public static ExpressionValue WorldObject(uint objectId) =>
new(ExpressionValueKind.WorldObject, objectId, null);
public static ExpressionValue Stopwatch(ExpressionStopwatch value) =>
new(ExpressionValueKind.Stopwatch, 0d, value);
public static ExpressionValue UiControl(ExpressionUiControl value) =>
new(ExpressionValueKind.UiControl, 0d, value);
public double AsNumber(string? operation = null)
{
if (Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean)
return _number;
throw TypeError(operation ?? "operation", "number");
}
public int AsInt32(string? operation = null) =>
Convert.ToInt32(AsNumber(operation), CultureInfo.InvariantCulture);
public string AsString(string? operation = null)
{
if (Kind == ExpressionValueKind.String)
return (string)_reference!;
throw TypeError(operation ?? "operation", "string");
}
public ExpressionList AsList(string? operation = null) =>
Kind == ExpressionValueKind.List
? (ExpressionList)_reference!
: throw TypeError(operation ?? "operation", "list");
public ExpressionDictionary AsDictionary(string? operation = null) =>
Kind == ExpressionValueKind.Dictionary
? (ExpressionDictionary)_reference!
: throw TypeError(operation ?? "operation", "dictionary");
public ExpressionCoordinates AsCoordinates(string? operation = null) =>
Kind == ExpressionValueKind.Coordinates
? (ExpressionCoordinates)_reference!
: throw TypeError(operation ?? "operation", "coordinates");
public ExpressionStopwatch AsStopwatch(string? operation = null) =>
Kind == ExpressionValueKind.Stopwatch
? (ExpressionStopwatch)_reference!
: throw TypeError(operation ?? "operation", "stopwatch");
public ExpressionUiControl AsUiControl(string? operation = null) =>
Kind == ExpressionValueKind.UiControl
? (ExpressionUiControl)_reference!
: throw TypeError(operation ?? "operation", "UI control");
public uint AsObjectId(string? operation = null) => Kind switch
{
ExpressionValueKind.WorldObject => checked((uint)_number),
ExpressionValueKind.Number => checked((uint)_number),
_ => throw TypeError(operation ?? "operation", "world object"),
};
public bool IsTruthy => Kind switch
{
ExpressionValueKind.Number or ExpressionValueKind.Boolean =>
_number != 0d,
ExpressionValueKind.String => ((string)_reference!).Length != 0,
_ => true,
};
public string ToDisplayString() => Kind switch
{
ExpressionValueKind.Number =>
_number.ToString("G15", CultureInfo.InvariantCulture),
ExpressionValueKind.Boolean => _number != 0d ? "True" : "False",
ExpressionValueKind.String => (string)_reference!,
ExpressionValueKind.List => _reference!.ToString()!,
ExpressionValueKind.Dictionary => _reference!.ToString()!,
ExpressionValueKind.Coordinates => _reference!.ToString()!,
ExpressionValueKind.WorldObject =>
checked((uint)_number).ToString(CultureInfo.InvariantCulture),
ExpressionValueKind.Stopwatch => _reference!.ToString()!,
ExpressionValueKind.UiControl => _reference!.ToString()!,
_ => string.Empty,
};
public bool Equals(ExpressionValue other)
{
if (Kind == ExpressionValueKind.String)
{
return other.Kind == ExpressionValueKind.String
&& string.Equals(
(string)_reference!,
(string)other._reference!,
StringComparison.OrdinalIgnoreCase);
}
if (Kind is ExpressionValueKind.Number or ExpressionValueKind.Boolean
&& other.Kind is ExpressionValueKind.Number
or ExpressionValueKind.Boolean)
{
return _number.Equals(other._number);
}
return Kind == other.Kind && ReferenceEquals(_reference, other._reference);
}
public override bool Equals(object? obj) =>
obj is ExpressionValue other && Equals(other);
public override int GetHashCode() => Kind switch
{
ExpressionValueKind.String => StringComparer.OrdinalIgnoreCase.GetHashCode(
(string)_reference!),
ExpressionValueKind.Number or ExpressionValueKind.Boolean =>
_number.GetHashCode(),
_ => HashCode.Combine(Kind, _reference),
};
public override string ToString() => ToDisplayString();
private ExpressionEvaluationException TypeError(
string operation,
string expected) => new(
$"{operation} expects {expected}, but received {Kind}.");
}
internal sealed class ExpressionParseException : Exception
{
public ExpressionParseException(string message, int offset)
: base($"{message} at offset {offset}.") => Offset = offset;
public int Offset { get; }
}
internal sealed class ExpressionEvaluationException : Exception
{
public ExpressionEvaluationException(string message, int offset = -1)
: base(offset < 0 ? message : $"{message} at offset {offset}.") =>
Offset = offset;
public int Offset { get; }
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,429 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Expressions;
/// <summary>
/// One shared expression lifetime for MossTank commands and Meta. Session,
/// persistent, and world-global variables therefore mean the same thing from
/// every entry point, just as they do in UtilityBelt.
/// </summary>
internal sealed class MossTankExpressionRuntime : IDisposable
{
private const int DefaultInstructionBudget = 10_000;
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly IPluginHost _host;
private readonly ExpressionState _state = new();
private readonly ExpressionFunctionRegistry _functions;
private readonly ExperienceMeter _experience;
private readonly QuestTracker _quests;
private readonly SalvageStagingManager _salvage;
private readonly StatusHudManager _statusHud;
private readonly List<DelayedExpression> _delayed = [];
private int _nextDelayId = 1;
private string _identity = string.Empty;
private string? _persistentJson;
private string? _globalJson;
private bool _disposed;
public MossTankExpressionRuntime(IPluginHost host, Random? random = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_experience = new ExperienceMeter(host);
_quests = new QuestTracker(host);
_salvage = new SalvageStagingManager(host);
_statusHud = new StatusHudManager(host);
_functions = CoreExpressionFunctions.CreateDefault(random);
HostExpressionFunctions.Register(_functions, host);
RegisterExperienceFunctions();
RegisterQuestFunctions();
RegisterSalvageFunctions();
RegisterStatusHudFunctions();
RegisterExecutionFunctions();
BindIdentity(force: true);
}
public ExpressionState State => _state;
internal ExpressionFunctionRegistry Registry => _functions;
public IReadOnlyCollection<ExpressionFunction> Functions => _functions.Functions;
public int PendingExecutionCount => _delayed.Count;
public ExpressionValue Evaluate(
string source,
int instructionBudget = DefaultInstructionBudget,
CancellationToken cancellationToken = default)
{
ObjectDisposedException.ThrowIf(_disposed, this);
BindIdentity(force: false);
ExpressionProgram program = ExpressionProgram.Compile(source);
var context = new ExpressionEvaluationContext(
_state,
_functions,
instructionBudget,
cancellationToken);
ExpressionValue result = program.Evaluate(context);
FlushVariables();
return result;
}
public void OnTick(double elapsedSeconds)
{
ObjectDisposedException.ThrowIf(_disposed, this);
BindIdentity(force: false);
if (elapsedSeconds < 0d || !double.IsFinite(elapsedSeconds))
throw new ArgumentOutOfRangeException(nameof(elapsedSeconds));
_experience.OnTick(elapsedSeconds);
_quests.OnTick(elapsedSeconds);
if (_delayed.Count == 0)
return;
double elapsedMilliseconds = elapsedSeconds * 1000d;
for (int index = 0; index < _delayed.Count; index++)
_delayed[index] = _delayed[index] with
{
RemainingMilliseconds =
_delayed[index].RemainingMilliseconds - elapsedMilliseconds,
};
DelayedExpression[] ready = _delayed
.Where(static delayed => delayed.RemainingMilliseconds <= 0d)
.OrderBy(static delayed => delayed.Id)
.ToArray();
if (ready.Length == 0)
return;
_delayed.RemoveAll(static delayed => delayed.RemainingMilliseconds <= 0d);
foreach (DelayedExpression delayed in ready)
{
try
{
Evaluate(delayed.Source);
}
catch (Exception error)
{
_host.Log.Error(
$"Delayed expression {delayed.Id} failed: {error.Message}");
}
}
}
public void ClearSession()
{
_state.Clear(ExpressionVariableScope.Session);
_delayed.Clear();
}
public void DestroyAuxiliaryViews() => _statusHud.Destroy();
public void Dispose()
{
if (_disposed)
return;
FlushVariables();
_delayed.Clear();
_statusHud.Destroy();
_disposed = true;
}
private void RegisterExecutionFunctions()
{
_functions.Register("exec", 1, 1, (context, args) =>
ExpressionProgram.Compile(args[0].AsString("exec")).Evaluate(context),
"exec[expression]");
_functions.Register("delayexec", 2, 2, (_, args) =>
{
double delay = Math.Max(0d, args[0].AsNumber("delayexec"));
string source = args[1].AsString("delayexec");
int id = NextDelayId();
_delayed.Add(new DelayedExpression(id, delay, source));
return ExpressionValue.Number(id);
}, "delayexec[milliseconds,expression]");
_functions.Register("clearexec", 1, 1, (_, args) =>
{
int id = args[0].AsInt32("clearexec");
return ExpressionValue.Boolean(
_delayed.RemoveAll(delayed => delayed.Id == id) != 0);
}, "clearexec[id]");
}
private void RegisterExperienceFunctions()
{
_functions.Register("xpreset", 0, 0, (_, _) =>
{
_experience.Reset();
return ExpressionValue.One;
}, "xpreset[]");
_functions.Register("xpmeter", 0, 0, (_, _) =>
ExpressionValue.String(_experience.Format()), "xpmeter[]");
_functions.Register("xpduration", 0, 0, (_, _) =>
ExpressionValue.Number(_experience.DurationSeconds), "xpduration[]");
_functions.Register("xptotal", 0, 0, (_, _) =>
ExpressionValue.Number(_experience.Experience), "xptotal[]");
_functions.Register("lumtotal", 0, 0, (_, _) =>
ExpressionValue.Number(_experience.Luminance), "lumtotal[]");
_functions.Register("xpavg", 0, 0, (_, _) =>
ExpressionValue.Number(_experience.ExperiencePerHour), "xpavg[]");
_functions.Register("lumavg", 0, 0, (_, _) =>
ExpressionValue.Number(_experience.LuminancePerHour), "lumavg[]");
}
private void RegisterQuestFunctions()
{
_functions.Register("testquestflag", 1, 1, (_, args) =>
ExpressionValue.Boolean(_quests.HasCompleted(
args[0].AsString("testquestflag"))), "testquestflag[questflag]");
_functions.Register("getqueststatus", 1, 1, (_, args) =>
ExpressionValue.Boolean(_quests.IsReady(
args[0].AsString("getqueststatus"))), "getqueststatus[questflag]");
_functions.Register("getquestktprogress", 1, 1, (_, args) =>
ExpressionValue.Number(_quests.Progress(
args[0].AsString("getquestktprogress"))),
"getquestktprogress[questflag]");
_functions.Register("getquestktrequired", 1, 1, (_, args) =>
ExpressionValue.Number(_quests.Required(
args[0].AsString("getquestktrequired"))),
"getquestktrequired[questflag]");
_functions.Register("isrefreshingquests", 0, 0, (_, _) =>
ExpressionValue.Boolean(_quests.IsRefreshing), "isrefreshingquests[]");
}
private void RegisterSalvageFunctions()
{
_functions.Register("ustadd", 1, 1, (_, args) =>
ExpressionValue.Boolean(_salvage.Add(
args[0].AsObjectId("ustadd"))), "ustadd[object]");
_functions.Register("ustopen", 0, 0, (_, _) =>
ExpressionValue.Boolean(_salvage.Open()), "ustopen[]");
_functions.Register("ustsalvage", 0, 0, (_, _) =>
ExpressionValue.Boolean(_salvage.Salvage()), "ustsalvage[]");
}
private void RegisterStatusHudFunctions()
{
_functions.Register("statushud", 2, 2, (_, args) =>
ExpressionValue.Boolean(_statusHud.Update(
args[0].AsString("statushud"),
args[1].ToDisplayString())),
"statushud[key,value]");
_functions.Register("statushudcolored", 3, 3, (_, args) =>
ExpressionValue.Boolean(_statusHud.Update(
args[0].AsString("statushudcolored"),
args[1].ToDisplayString(),
checked((uint)args[2].AsNumber("statushudcolored")))),
"statushudcolored[key,value,rgb]");
}
private int NextDelayId()
{
int initial = _nextDelayId;
do
{
int candidate = _nextDelayId++;
if (_nextDelayId <= 0)
_nextDelayId = 1;
if (_delayed.All(delayed => delayed.Id != candidate))
return candidate;
}
while (_nextDelayId != initial);
throw new ExpressionEvaluationException("No delayed-expression ids remain");
}
private void BindIdentity(bool force)
{
ICharacterInfo character = _host.Automation.Character;
string identity = string.Join(
'\n',
character.WorldName,
character.AccountName,
character.Name);
if (!force && identity.Equals(_identity, StringComparison.Ordinal))
return;
if (_identity.Length != 0)
FlushVariables();
_identity = identity;
_quests.BindIdentity(identity);
_salvage.Clear();
_state.Clear(ExpressionVariableScope.Session);
_delayed.Clear();
_experience.Reset();
_persistentJson = LoadScope(ExpressionVariableScope.Persistent);
_globalJson = LoadScope(ExpressionVariableScope.Global);
}
private string? LoadScope(ExpressionVariableScope scope)
{
_state.Clear(scope);
if (!_host.Storage.IsAvailable || _identity.Length == 0)
return null;
try
{
string? json = _host.Storage.ReadText(StorageKey(scope));
if (string.IsNullOrWhiteSpace(json))
return null;
Dictionary<string, StoredValue>? document = JsonSerializer.Deserialize<
Dictionary<string, StoredValue>>(json, JsonOptions);
if (document is not null)
{
_state.Replace(scope, document.Select(static pair =>
new KeyValuePair<string, ExpressionValue>(
pair.Key,
Restore(pair.Value))));
}
return json;
}
catch (Exception error)
{
_host.Log.Error($"Unable to load {scope} expression variables: {error.Message}");
return null;
}
}
private void FlushVariables()
{
if (!_host.Storage.IsAvailable || _identity.Length == 0)
return;
_persistentJson = FlushScope(
ExpressionVariableScope.Persistent,
_persistentJson);
_globalJson = FlushScope(ExpressionVariableScope.Global, _globalJson);
}
private string? FlushScope(ExpressionVariableScope scope, string? previous)
{
try
{
Dictionary<string, StoredValue> document = _state.Capture(scope)
.ToDictionary(
static pair => pair.Key,
static pair => Store(pair.Value),
StringComparer.OrdinalIgnoreCase);
string json = JsonSerializer.Serialize(document, JsonOptions);
if (!json.Equals(previous, StringComparison.Ordinal))
_host.Storage.WriteText(StorageKey(scope), json);
return json;
}
catch (Exception error)
{
_host.Log.Error($"Unable to save {scope} expression variables: {error.Message}");
return previous;
}
}
private string StorageKey(ExpressionVariableScope scope)
{
ICharacterInfo character = _host.Automation.Character;
string owner = scope == ExpressionVariableScope.Persistent
? string.Join('\n', character.WorldName, character.AccountName, character.Name)
: string.Join('\n', character.WorldName, character.AccountName);
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(owner));
return $"expressions/{scope.ToString().ToLowerInvariant()}/"
+ $"{Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant()}.json";
}
private static StoredValue Store(in ExpressionValue value) => value.Kind switch
{
ExpressionValueKind.Number => new StoredValue
{
Kind = "number",
Number = value.AsNumber(),
},
ExpressionValueKind.Boolean => new StoredValue
{
Kind = "boolean",
Number = value.AsNumber(),
},
ExpressionValueKind.String => new StoredValue
{
Kind = "string",
Text = value.AsString(),
},
ExpressionValueKind.List => new StoredValue
{
Kind = "list",
List = value.AsList().Items.Select(static item => Store(item)).ToList(),
},
ExpressionValueKind.Dictionary => new StoredValue
{
Kind = "dictionary",
Dictionary = value.AsDictionary().Items.ToDictionary(
static pair => pair.Key,
static pair => Store(pair.Value),
StringComparer.Ordinal),
},
ExpressionValueKind.Coordinates => StoreCoordinates(value.AsCoordinates()),
ExpressionValueKind.WorldObject => new StoredValue
{
Kind = "worldobject",
Number = value.AsObjectId(),
},
_ => throw new ExpressionEvaluationException(
$"{value.Kind} values cannot be persisted"),
};
private static StoredValue StoreCoordinates(in ExpressionCoordinates value) => new()
{
Kind = "coordinates",
Coordinates =
[
value.EastWest,
value.NorthSouth,
value.Elevation,
],
};
private static ExpressionValue Restore(StoredValue value) =>
value.Kind.ToLowerInvariant() switch
{
"number" => ExpressionValue.Number(value.Number),
"boolean" => ExpressionValue.Boolean(value.Number != 0d),
"string" => ExpressionValue.String(value.Text),
"list" => ExpressionValue.List(new ExpressionList(
(value.List ?? []).Select(Restore))),
"dictionary" => RestoreDictionary(value.Dictionary),
"coordinates" => RestoreCoordinates(value.Coordinates),
"worldobject" => ExpressionValue.WorldObject(checked((uint)value.Number)),
_ => ExpressionValue.Zero,
};
private static ExpressionValue RestoreDictionary(
Dictionary<string, StoredValue>? values)
{
var result = new ExpressionDictionary();
if (values is not null)
{
foreach ((string key, StoredValue value) in values)
result.Items[key] = Restore(value);
}
return ExpressionValue.Dictionary(result);
}
private static ExpressionValue RestoreCoordinates(double[]? values) =>
values is { Length: >= 2 }
? ExpressionValue.Coordinates(new ExpressionCoordinates(
values[0],
values[1],
values.Length >= 3 ? values[2] : 0d))
: ExpressionValue.Zero;
private sealed class StoredValue
{
public string Kind { get; set; } = "number";
public double Number { get; set; }
public string Text { get; set; } = string.Empty;
public List<StoredValue>? List { get; set; }
public Dictionary<string, StoredValue>? Dictionary { get; set; }
public double[]? Coordinates { get; set; }
}
private readonly record struct DelayedExpression(
int Id,
double RemainingMilliseconds,
string Source);
}

View file

@ -0,0 +1,152 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Expressions;
/// <summary>
/// UtilityBelt-compatible /myquests cache. The server remains authoritative;
/// this owner only parses the same lines UB consumes and never invents flags.
/// </summary>
internal sealed partial class QuestTracker(IPluginHost host)
{
private const double CompletionSilenceSeconds = 1d;
private const double RetrySeconds = 15d;
private const int MaximumAttempts = 3;
private readonly Dictionary<string, QuestFlag> _flags =
new(StringComparer.OrdinalIgnoreCase);
private ulong _chatSequence;
private string _identity = string.Empty;
private double _silenceSeconds;
private int _attemptsRemaining;
private bool _receivedFlag;
public bool IsRefreshing { get; private set; }
public int Count => _flags.Count;
public void BindIdentity(string identity)
{
if (identity.Equals(_identity, StringComparison.Ordinal))
return;
_identity = identity;
_flags.Clear();
IsRefreshing = false;
_receivedFlag = false;
_silenceSeconds = 0d;
if (!string.IsNullOrWhiteSpace(identity))
Refresh();
}
public void Refresh()
{
if (IsRefreshing)
return;
_flags.Clear();
_attemptsRemaining = MaximumAttempts;
_receivedFlag = false;
_silenceSeconds = 0d;
IsRefreshing = true;
SubmitRequest();
}
public void OnTick(double elapsedSeconds)
{
CaptureChat();
if (!IsRefreshing)
return;
_silenceSeconds += elapsedSeconds;
if (_receivedFlag && _silenceSeconds > CompletionSilenceSeconds)
{
IsRefreshing = false;
return;
}
if (!_receivedFlag && _silenceSeconds > RetrySeconds)
SubmitRequest();
}
public bool HasCompleted(string key) =>
_flags.ContainsKey(Normalize(key));
public bool IsReady(string key)
{
if (!_flags.TryGetValue(Normalize(key), out QuestFlag flag))
return true;
DateTimeOffset next = flag.CompletedOn.AddSeconds(flag.RepeatSeconds);
if (next > DateTimeOffset.UtcNow)
return false;
return !(flag.MaxSolves == 1 && flag.Solves <= 1);
}
public int Progress(string key) =>
_flags.TryGetValue(Normalize(key), out QuestFlag flag) ? flag.Solves : 0;
public int Required(string key) =>
_flags.TryGetValue(Normalize(key), out QuestFlag flag) ? flag.MaxSolves : 0;
private void CaptureChat()
{
foreach (PluginChatMessage message in host.Automation.Chat
.CaptureMessages(_chatSequence).OrderBy(static message => message.Sequence))
{
_chatSequence = Math.Max(_chatSequence, message.Sequence);
string text = message.Text.Trim();
if (text.Equals("Quest list is empty.", StringComparison.Ordinal)
|| text.Equals(
"The command \"myquests\" is not currently enabled on this server.",
StringComparison.Ordinal))
{
IsRefreshing = false;
continue;
}
Match match = MyQuestLine().Match(text);
if (!match.Success)
continue;
if (!int.TryParse(match.Groups["solves"].Value,
NumberStyles.Integer, CultureInfo.InvariantCulture, out int solves)
|| !long.TryParse(match.Groups["completedOn"].Value,
NumberStyles.Integer, CultureInfo.InvariantCulture, out long completed))
{
continue;
}
_ = int.TryParse(match.Groups["maxSolves"].Value,
NumberStyles.Integer, CultureInfo.InvariantCulture, out int maximum);
_ = long.TryParse(match.Groups["repeatTime"].Value,
NumberStyles.Integer, CultureInfo.InvariantCulture, out long repeat);
string key = Normalize(match.Groups["key"].Value);
_flags[key] = new QuestFlag(
solves,
maximum,
DateTimeOffset.FromUnixTimeSeconds(Math.Max(0L, completed)),
Math.Max(0L, repeat));
_receivedFlag = true;
_silenceSeconds = 0d;
}
}
private void SubmitRequest()
{
if (_attemptsRemaining <= 0)
{
IsRefreshing = false;
return;
}
_attemptsRemaining--;
_silenceSeconds = 0d;
host.Automation.Chat.Submit("/myquests");
}
private static string Normalize(string key) => key.Trim().ToLowerInvariant();
[GeneratedRegex(
"(?<key>\\S+) \\- (?<solves>\\d+) solves \\((?<completedOn>\\d{0,11})\\)\"?((?<description>.*)\" (?<maxSolves>.*) (?<repeatTime>\\d{0,11}))?.*$",
RegexOptions.CultureInvariant,
100)]
private static partial Regex MyQuestLine();
private readonly record struct QuestFlag(
int Solves,
int MaxSolves,
DateTimeOffset CompletedOn,
long RepeatSeconds);
}

View file

@ -0,0 +1,59 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Expressions;
/// <summary>UtilityBelt UST expression staging over the canonical salvage command.</summary>
internal sealed class SalvageStagingManager(IPluginHost host)
{
private readonly HashSet<uint> _staged = [];
public int Count => _staged.Count;
public bool Add(uint objectId)
{
if (!host.Automation.Items.CaptureOwnedItems()
.Any(item => item.ObjectId == objectId && !item.IsEquipped))
{
return false;
}
_staged.Add(objectId);
return true;
}
public bool Open()
{
PluginInventoryItem? ust = host.Automation.Items.CaptureOwnedItems()
.Where(static item => item.Name.Equals("Ust", StringComparison.Ordinal))
.OrderBy(static item => item.ObjectId)
.Cast<PluginInventoryItem?>()
.FirstOrDefault();
return ust is { } found
&& host.Automation.Items.Use(found.ObjectId).Accepted;
}
public bool Salvage()
{
IReadOnlyList<PluginInventoryItem> inventory =
host.Automation.Items.CaptureOwnedItems();
PluginInventoryItem? ust = inventory
.Where(static item => item.Name.Equals("Ust", StringComparison.Ordinal))
.OrderBy(static item => item.ObjectId)
.Cast<PluginInventoryItem?>()
.FirstOrDefault();
if (ust is not { } tool)
return false;
uint[] items = inventory
.Where(item => item.ObjectId != tool.ObjectId && _staged.Contains(item.ObjectId))
.Select(static item => item.ObjectId)
.ToArray();
if (items.Length == 0
|| !host.Automation.Items.Salvage(tool.ObjectId, items).Accepted)
{
return false;
}
_staged.Clear();
return true;
}
public void Clear() => _staged.Clear();
}

View file

@ -0,0 +1,68 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank.Expressions;
/// <summary>VTank Meta status HUD backed by one shelf-managed plugin window.</summary>
internal sealed class StatusHudManager(IPluginHost host)
{
private const uint DefaultColor = 0xE8DEC3u;
private const string Markup = """
<panel x="520" y="42" w="340" h="220" title="VTank Meta Status"
visible="{WindowAvailable}" resize="none">
<label x="8" y="25" text="VTank Meta" color="#FFE8DEC3" />
<list x="8" y="45" w="324" h="166" rowheight="18"
items="{Rows}" colors="{RowColors}" selected="{SelectedRow}" />
</panel>
""";
private readonly Dictionary<string, StatusEntry> _entries =
new(StringComparer.Ordinal);
private readonly StatusBinding _binding = new();
private IDisposable? _registration;
public int Count => _entries.Count;
internal IReadOnlyList<string> Rows => _binding.Rows;
internal IReadOnlyList<uint> RowColors => _binding.RowColors;
public bool Update(string key, string value, uint? color = null)
{
if (string.IsNullOrEmpty(key))
return false;
_entries[key] = new StatusEntry(value ?? string.Empty, color ?? DefaultColor);
_binding.Rows = _entries.Select(static pair =>
$"{pair.Key}: {pair.Value.Value}").ToArray();
_binding.RowColors = _entries.Select(static pair => pair.Value.Color).ToArray();
if (_registration is null && host.HasUi)
{
_registration = host.Ui.RegisterPanelContent(
new PluginPanelDescriptor("vtank-meta-status", "VTank Meta Status")
{
IconText = "S",
StartVisible = true,
ShowInSidePanel = true,
},
Markup,
_binding);
}
return true;
}
public void Destroy()
{
_registration?.Dispose();
_registration = null;
_entries.Clear();
_binding.Rows = [];
_binding.RowColors = [];
}
private readonly record struct StatusEntry(string Value, uint Color);
private sealed class StatusBinding
{
public bool WindowAvailable => true;
public IReadOnlyList<string> Rows { get; internal set; } = [];
public IReadOnlyList<uint> RowColors { get; internal set; } = [];
public int SelectedRow => -1;
}
}

View file

@ -0,0 +1,523 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// VTank's tell-driven fellowship manager: waiting-list recruitment, status
/// commands, and two-minute member votes. The host owns only the retail wire
/// commands; every queue and vote remains plugin policy.
/// </summary>
internal sealed class FellowshipManager
{
private const int MaximumOtherMembers = 8;
private const double RequestLifetimeSeconds = 300d;
private const double VoteLifetimeSeconds = 120d;
private const double VoteCallerCooldownSeconds = 240d;
private const double RecruitRangeMeters = 10d;
private readonly IPluginHost _host;
private readonly List<WaitingPlayer> _waiting = [];
private readonly HashSet<string> _banned =
new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, double> _voteCooldowns =
new(StringComparer.OrdinalIgnoreCase);
private readonly List<FellowVote> _votes = [];
private readonly Dictionary<string, Queue<double>> _tellRate =
new(StringComparer.OrdinalIgnoreCase);
private ulong _chatSequence;
private double _now;
private double _nextRecruitAt;
private int _nextVoteId = 1;
private bool _wasLeader;
private bool _desiredOpen = true;
public FellowshipManager(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
}
public string Status { get; private set; } = "Fellow manager idle";
public IReadOnlyList<string> WaitingNames =>
_waiting.Select(static value => value.Name).ToArray();
public void Tick(double elapsedSeconds, bool enabled)
{
_now += Math.Max(0d, elapsedSeconds);
IReadOnlyList<PluginChatMessage> messages =
_host.Automation.Chat.CaptureMessages(_chatSequence);
foreach (PluginChatMessage message in messages)
{
_chatSequence = Math.Max(_chatSequence, message.Sequence);
if (enabled && IsIncomingTell(message))
HandleTell(message);
}
IFellowshipAutomation fellowship = _host.Automation.Fellowship;
if (!enabled || !fellowship.IsInFellowship)
{
Status = enabled ? "Not in a fellowship" : "Fellow manager disabled";
if (!fellowship.IsInFellowship)
ResetSocialState();
return;
}
bool isLeader = fellowship.LeaderObjectId == _host.Automation.Character.ObjectId;
if (_wasLeader && !isLeader)
{
if (_votes.Count != 0)
Fellow("[VT Fellow Manager] I am no longer the fellowship leader. All votes have been canceled. -v-");
_votes.Clear();
_waiting.Clear();
_banned.Clear();
}
_wasLeader = isLeader;
RemoveJoinedPlayers(fellowship.CaptureRoster());
ExpireVotes(isLeader);
ExpireWaitingPlayers();
if (isLeader)
RecruitNext(fellowship);
Status = isLeader
? $"Fellow leader — {_waiting.Count} waiting, {_votes.Count} vote(s)"
: $"Fellow member — leader {LeaderName(fellowship)}";
}
public void Reset()
{
_chatSequence = 0u;
_now = 0d;
_nextRecruitAt = 0d;
_nextVoteId = 1;
_wasLeader = false;
ResetSocialState();
_tellRate.Clear();
Status = "Fellow manager idle";
}
private void HandleTell(PluginChatMessage message)
{
string sender = message.Sender.Trim();
string command = message.Text.Trim();
if (sender.Length == 0 || command.Length == 0 || IsSpam(sender))
return;
IFellowshipAutomation fellowship = _host.Automation.Fellowship;
IReadOnlyList<PluginFellowMember> roster = fellowship.CaptureRoster();
bool isMember = roster.Any(member => member.Name.Equals(
sender, StringComparison.OrdinalIgnoreCase));
bool isLeader = fellowship.LeaderObjectId == _host.Automation.Character.ObjectId;
if (command.Equals("xp", StringComparison.OrdinalIgnoreCase))
{
RequestRecruit(sender, message.SenderObjectId, roster, isLeader);
return;
}
if (command.Equals("line", StringComparison.OrdinalIgnoreCase)
|| command.Equals("list", StringComparison.OrdinalIgnoreCase)
|| command.Equals("status", StringComparison.OrdinalIgnoreCase))
{
SendLineStatus(sender, fellowship, isLeader);
return;
}
if (command.Equals("remove", StringComparison.OrdinalIgnoreCase))
{
RemoveWaiting(sender);
Tell(sender, "[VT Fellow Manager] You have been removed from the list. -v-");
return;
}
if (command.Equals("leader", StringComparison.OrdinalIgnoreCase))
{
string openness = fellowship.IsOpen ? "open" : "closed";
Tell(sender, isLeader
? $"[VT Fellow Manager] I am the fellowship leader. The fellowship is {openness}. -v-"
: $"[VT Fellow Manager] The leader is currently: {LeaderName(fellowship)}. The fellowship is {openness}. -v-");
return;
}
if (command.Equals("help", StringComparison.OrdinalIgnoreCase))
{
Tell(sender, "[VT Fellow Manager] Available commands: xp, line, remove, leader, startvote, vote, location, help -v-");
return;
}
if (command.Equals("help startvote", StringComparison.OrdinalIgnoreCase))
{
Tell(sender, "[VT Fellow Manager] Usage: startvote [votetype] [parameter]. Possible vote types: kick, ban, giveleader, setopen. -v-");
return;
}
if (command.Equals("help vote", StringComparison.OrdinalIgnoreCase))
{
Tell(sender, "[VT Fellow Manager] Usage: vote [vote id] [yes/no] -v-");
return;
}
if (command.StartsWith("startvote ", StringComparison.OrdinalIgnoreCase))
{
StartVote(sender, command, roster, isMember, isLeader);
return;
}
if (command.StartsWith("vote ", StringComparison.OrdinalIgnoreCase))
{
CastVote(sender, command, isMember);
return;
}
if (command.Equals("location", StringComparison.OrdinalIgnoreCase))
{
Tell(sender, isMember
? $"[VT Fellow Manager] I am currently located in landcell: {_host.Automation.Navigation.Snapshot.Position.CellId:X8} -v-"
: "[VT Fellow Manager] Sorry, I can only send my location to members of the fellowship. -v-");
}
}
private void RequestRecruit(
string sender,
uint senderObjectId,
IReadOnlyList<PluginFellowMember> roster,
bool isLeader)
{
if (roster.Any(member => member.Name.Equals(
sender, StringComparison.OrdinalIgnoreCase)))
{
Tell(sender, "[VT Fellow Manager] You are already in the fellowship. -v-");
return;
}
if (_banned.Contains(sender))
{
Tell(sender, "[VT Fellow Manager] Sorry, but you have been banned from this fellowship. -v-");
return;
}
IFellowshipAutomation fellowship = _host.Automation.Fellowship;
if (!isLeader && !fellowship.IsOpen)
{
Tell(sender, $"[VT Fellow Manager] I'm sorry, but the fellowship is closed and I am not the leader. The leader is currently: {LeaderName(fellowship)} -v-");
return;
}
WaitingPlayer? existing = _waiting.FirstOrDefault(value =>
value.Name.Equals(sender, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
existing.ObjectId = senderObjectId != 0u ? senderObjectId : existing.ObjectId;
existing.ExpiresAt = _now + RequestLifetimeSeconds;
int position = _waiting.IndexOf(existing) + 1;
Tell(sender, $"[VT Fellow Manager] You are already number {position} of {_waiting.Count} on the waiting list. -v-");
return;
}
_waiting.Add(new WaitingPlayer(
sender,
senderObjectId,
_now + RequestLifetimeSeconds));
if (isLeader && roster.Count >= MaximumOtherMembers + 1)
{
_desiredOpen = fellowship.IsOpen;
fellowship.SetOpen(false);
Tell(sender, $"[VT Fellow Manager] The fellow is full, and I am the leader. I am adding you to the waiting list at position {_waiting.Count} -v-");
}
else
{
Tell(sender, "[VT Fellow Manager] I will recruit you in a moment. Please stand close to me. -v-");
}
}
private void RecruitNext(IFellowshipAutomation fellowship)
{
if (_waiting.Count == 0)
{
if (fellowship.IsOpen != _desiredOpen)
fellowship.SetOpen(_desiredOpen);
return;
}
if (fellowship.CaptureRoster().Count >= MaximumOtherMembers + 1)
{
if (fellowship.IsOpen)
fellowship.SetOpen(false);
return;
}
if (_now < _nextRecruitAt)
return;
WaitingPlayer player = _waiting[0];
if (player.ObjectId == 0u || !IsNear(player.ObjectId))
{
player.Attempts++;
_nextRecruitAt = _now + 1d;
if (player.Attempts == 16)
Tell(player.Name, "[VT Fellow Manager] You are too far away. I will wait 20 seconds and give you one more chance. -v-");
if (player.Attempts > 30)
{
Tell(player.Name, "[VT Fellow Manager] I'm sorry, but I couldn't recruit you. Please try again. -v-");
_waiting.RemoveAt(0);
}
return;
}
PluginFellowshipCommandResult result = fellowship.Recruit(player.ObjectId);
_nextRecruitAt = _now + 1d;
if (!result.Accepted)
player.Attempts++;
}
private void StartVote(
string sender,
string command,
IReadOnlyList<PluginFellowMember> roster,
bool isMember,
bool isLeader)
{
if (!isMember || _banned.Contains(sender))
return;
if (!isLeader)
{
Tell(sender, "[VT Fellow Manager] I am not the fellowship leader and cannot manage votes. -v-");
return;
}
if (_voteCooldowns.TryGetValue(sender, out double readyAt) && readyAt > _now)
{
Tell(sender, "[VT Fellow Manager] You have initiated a vote too recently. -v-");
return;
}
string[] parts = command.Split(' ', 3, StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3)
{
Tell(sender, "[VT Fellow Manager] Not enough parameters to startvote command. Tell me 'help startvote' for more information. -v-");
return;
}
string kindText = parts[1].ToLowerInvariant();
string parameter = parts[2].Trim();
FellowVoteKind kind;
if (kindText is "kick" or "ban" or "giveleader")
{
if (!roster.Any(member => member.Name.Equals(
parameter, StringComparison.OrdinalIgnoreCase)))
{
Tell(sender, $"[VT Fellow Manager] Cannot vote to {kindText} {parameter}, that player is not in the fellow. -v-");
return;
}
kind = kindText switch
{
"kick" => FellowVoteKind.Kick,
"ban" => FellowVoteKind.Ban,
_ => FellowVoteKind.GiveLeader,
};
}
else if (kindText == "setopen"
&& bool.TryParse(parameter, out _))
{
kind = FellowVoteKind.SetOpen;
parameter = parameter.ToLowerInvariant();
}
else
{
Tell(sender, "[VT Fellow Manager] Unknown vote type. Tell me 'help startvote' for more information. -v-");
return;
}
if (_votes.Any(value => value.Kind == kind
&& value.Parameter.Equals(parameter, StringComparison.OrdinalIgnoreCase)))
{
Tell(sender, "[VT Fellow Manager] An identical vote is already in progress! -v-");
return;
}
var vote = new FellowVote(
_nextVoteId++, kind, parameter, _now + VoteLifetimeSeconds);
vote.Ballots[sender] = true;
_votes.Add(vote);
_voteCooldowns[sender] = _now + VoteCallerCooldownSeconds;
Fellow($"[VT Fellow Manager] {sender} has called a new vote: {kindText} {parameter}! To vote, tell me 'vote {vote.Id} yes' or 'vote {vote.Id} no'. You have 2 minutes. -v-");
AnnounceVote(vote);
}
private void CastVote(string sender, string command, bool isMember)
{
if (!isMember || _banned.Contains(sender))
return;
string[] parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 3
|| !int.TryParse(parts[1], out int id)
|| !(parts[2].Equals("yes", StringComparison.OrdinalIgnoreCase)
|| parts[2].Equals("no", StringComparison.OrdinalIgnoreCase)))
{
Tell(sender, "[VT Fellow Manager] Invalid vote command. Votes should look like: vote idnumber yes, or: vote idnumber no -v-");
return;
}
FellowVote? vote = _votes.FirstOrDefault(value => value.Id == id);
if (vote is null)
{
Tell(sender, "[VT Fellow Manager] Invalid vote ID number. Votes should look like: vote idnumber yes, or: vote idnumber no -v-");
return;
}
vote.Ballots[sender] = parts[2].Equals("yes", StringComparison.OrdinalIgnoreCase);
AnnounceVote(vote);
}
private void ExpireVotes(bool isLeader)
{
foreach (FellowVote vote in _votes.Where(value => value.ExpiresAt <= _now).ToArray())
{
_votes.Remove(vote);
int yes = vote.Ballots.Values.Count(static value => value);
int no = vote.Ballots.Count - yes;
bool passed = yes > (yes + no) / 2;
Fellow($"[VT Fellow Manager] Vote {vote.Description} {(passed ? "passed" : "failed")} ({yes}/{no}). -v-");
if (passed && isLeader)
ExecuteVote(vote);
}
}
private void ExecuteVote(FellowVote vote)
{
IFellowshipAutomation fellowship = _host.Automation.Fellowship;
PluginFellowMember target = fellowship.CaptureRoster().FirstOrDefault(member =>
member.Name.Equals(vote.Parameter, StringComparison.OrdinalIgnoreCase));
switch (vote.Kind)
{
case FellowVoteKind.Kick when target.ObjectId != 0u:
fellowship.Dismiss(target.ObjectId);
break;
case FellowVoteKind.Ban when target.ObjectId != 0u:
_banned.Add(target.Name);
fellowship.Dismiss(target.ObjectId);
break;
case FellowVoteKind.GiveLeader when target.ObjectId != 0u:
fellowship.AssignLeader(target.ObjectId);
break;
case FellowVoteKind.SetOpen:
_desiredOpen = bool.Parse(vote.Parameter);
fellowship.SetOpen(_desiredOpen);
break;
}
}
private void SendLineStatus(
string sender,
IFellowshipAutomation fellowship,
bool isLeader)
{
if (!isLeader)
{
Tell(sender, $"[VT Fellow Manager] The leader is currently: {LeaderName(fellowship)}. The fellowship is {(fellowship.IsOpen ? "open" : "closed")}. -v-");
return;
}
WaitingPlayer? waiting = _waiting.FirstOrDefault(value =>
value.Name.Equals(sender, StringComparison.OrdinalIgnoreCase));
if (waiting is null)
{
Tell(sender, _waiting.Count == 0
? $"[VT Fellow Manager] There is no waiting list. The fellowship has {fellowship.CaptureRoster().Count} members. -v-"
: $"[VT Fellow Manager] The waiting list contains {_waiting.Count} players. You are not on it. -v-");
return;
}
Tell(sender, $"[VT Fellow Manager] You are number {_waiting.IndexOf(waiting) + 1} of {_waiting.Count} on the waiting list. -v-");
}
private void RemoveJoinedPlayers(IReadOnlyList<PluginFellowMember> roster)
{
_waiting.RemoveAll(waiting => roster.Any(member => member.Name.Equals(
waiting.Name, StringComparison.OrdinalIgnoreCase)));
foreach (FellowVote vote in _votes)
{
foreach (string voter in vote.Ballots.Keys
.Where(name => !roster.Any(member => member.Name.Equals(
name, StringComparison.OrdinalIgnoreCase)))
.ToArray())
{
vote.Ballots.Remove(voter);
}
}
}
private void ExpireWaitingPlayers()
{
foreach (WaitingPlayer player in _waiting
.Where(value => value.ExpiresAt <= _now).ToArray())
{
_waiting.Remove(player);
Tell(player.Name, "[VT Fellow Manager] Your spot in the fellowship has expired. You have been removed from the list. -v-");
}
}
private bool IsNear(uint objectId)
{
INavigationAutomation navigation = _host.Automation.Navigation;
PluginNavigationSnapshot self = navigation.Snapshot;
return self.IsAvailable
&& navigation.TryGetObject(objectId, out PluginNavigationObject target)
&& self.Position.HorizontalDistanceMeters(target.Position)
<= RecruitRangeMeters;
}
private bool IsSpam(string sender)
{
if (!_tellRate.TryGetValue(sender, out Queue<double>? times))
{
times = new Queue<double>();
_tellRate[sender] = times;
}
while (times.Count != 0 && times.Peek() <= _now - 180d)
times.Dequeue();
times.Enqueue(_now);
return times.Count > 8;
}
private static bool IsIncomingTell(in PluginChatMessage message) =>
message.Kind == 3 && message.SenderObjectId != 0u;
private string LeaderName(IFellowshipAutomation fellowship) =>
fellowship.CaptureRoster().FirstOrDefault(member =>
member.ObjectId == fellowship.LeaderObjectId).Name is { Length: > 0 } name
? name
: "????";
private void RemoveWaiting(string name) => _waiting.RemoveAll(value =>
value.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
private void AnnounceVote(FellowVote vote)
{
int yes = vote.Ballots.Values.Count(static value => value);
int no = vote.Ballots.Count - yes;
Fellow($"[VT Fellow Manager] Vote total for {vote.Description}: {yes}/{no} -v-");
}
private void Tell(string player, string text) =>
_host.Automation.Chat.Submit($"/t {player}, {text}");
private void Fellow(string text) =>
_host.Automation.Chat.Submit("/f " + text);
private void ResetSocialState()
{
_waiting.Clear();
_banned.Clear();
_voteCooldowns.Clear();
_votes.Clear();
_wasLeader = false;
}
private sealed class WaitingPlayer(
string name,
uint objectId,
double expiresAt)
{
public string Name { get; } = name;
public uint ObjectId { get; set; } = objectId;
public double ExpiresAt { get; set; } = expiresAt;
public int Attempts { get; set; }
}
private enum FellowVoteKind
{
Kick,
Ban,
GiveLeader,
SetOpen,
}
private sealed class FellowVote(
int id,
FellowVoteKind kind,
string parameter,
double expiresAt)
{
public int Id { get; } = id;
public FellowVoteKind Kind { get; } = kind;
public string Parameter { get; } = parameter;
public double ExpiresAt { get; } = expiresAt;
public Dictionary<string, bool> Ballots { get; } =
new(StringComparer.OrdinalIgnoreCase);
public string Description => $"'{Kind} {Parameter}' (ID {Id})";
}
}

View file

@ -0,0 +1,79 @@
namespace AcDream.Plugins.MossTank;
internal readonly record struct GrenadeDefinition(
string Name,
uint SpellId,
int Spellcraft,
int RequiredAlchemy);
/// <summary>
/// VTank's exact 72-entry GameInfoDB GrenadeOptions table. The source is the
/// official Virindi update feed (DB version 9), not an inferred name pattern.
/// </summary>
internal static class GrenadeCatalog
{
private readonly record struct Tier(
string Name,
int RequiredAlchemy,
int Spellcraft,
uint Imperil,
uint Blade,
uint Acid,
uint Cold,
uint Bludgeon,
uint Fire,
uint Piercing,
uint Lightning,
uint Fester);
private static readonly Tier[] Tiers =
[
new("Iron", 75, 100, 1323, 1128, 522, 1061, 1049, 1104, 1152, 1085, 172),
new("Copper", 125, 160, 1324, 1129, 523, 1062, 1050, 1105, 1153, 1086, 173),
new("Silver", 175, 220, 1325, 1130, 524, 1063, 1051, 1106, 1154, 1087, 174),
new("Gold", 225, 270, 1326, 1131, 525, 1064, 1052, 1107, 1155, 1088, 175),
new("Pyreal", 275, 340, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176),
new("Platinum", 325, 400, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176),
new("Empowered Platinum", 375, 460, 1327, 1132, 526, 1065, 1053, 1108, 1156, 1089, 176),
new("Mana", 400, 520, 2074, 2164, 2162, 2168, 2166, 2170, 2174, 2172, 2178),
];
private static readonly IReadOnlyList<GrenadeDefinition> Entries = Build();
private static readonly IReadOnlyDictionary<string, GrenadeDefinition> ByName =
Entries.ToDictionary(entry => entry.Name, StringComparer.Ordinal);
public static IReadOnlyList<GrenadeDefinition> All => Entries;
public static bool TryGet(string exactName, out GrenadeDefinition definition) =>
ByName.TryGetValue(exactName, out definition);
private static IReadOnlyList<GrenadeDefinition> Build()
{
var result = new List<GrenadeDefinition>(72);
foreach (Tier tier in Tiers)
{
Add(result, tier, "Imperil", tier.Imperil);
Add(result, tier, "Blade Vulnerability", tier.Blade);
Add(result, tier, "Acid Vulnerability", tier.Acid);
Add(result, tier, "Cold Vulnerability", tier.Cold);
Add(result, tier, "Bludgeon Vulnerability", tier.Bludgeon);
Add(result, tier, "Fire Vulnerability", tier.Fire);
Add(result, tier, "Piercing Vulnerability", tier.Piercing);
Add(result, tier, "Lightning Vulnerability", tier.Lightning);
}
foreach (Tier tier in Tiers)
Add(result, tier, "Fester", tier.Fester);
return result;
}
private static void Add(
ICollection<GrenadeDefinition> result,
Tier tier,
string effect,
uint spellId) =>
result.Add(new GrenadeDefinition(
$"{tier.Name} Phial of {effect}",
spellId,
tier.Spellcraft,
tier.RequiredAlchemy));
}

View file

@ -0,0 +1,299 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal sealed class InventorySettings
{
public bool ManaChargesWhenOff { get; set; } = true;
// Official VTank defaults from uTank2.Resources.defaultsettings.usd.
public bool AutoStack { get; set; } = true;
public bool AutoCram { get; set; }
public bool AutoCraftItems { get; set; } = true;
public int ArrowheadFletchDifficultyExcess { get; set; } = 10;
public bool SplitPeas { get; set; } = true;
public int CriticalComponentMinimum { get; set; } = 4;
public int NormalComponentMinimum { get; set; } = 20;
public int IdleComponentMinimum { get; set; } = 20;
public int IdleHealthKitCount { get; set; } = 2;
public int IdleStaminaKitCount { get; set; } = 2;
public int IdleManaKitCount { get; set; } = 2;
public int IdleHealthFoodCount { get; set; } = 15;
public int IdleStaminaFoodCount { get; set; } = 15;
public int IdleManaFoodCount { get; set; } = 15;
public bool RefillWornMana { get; set; } = true;
public int RefillWornManaPercent { get; set; } = 33;
public double ScanIntervalSeconds { get; set; } = 0.25d;
public LootSettings Loot { get; } = new();
}
internal enum InventoryMaintenanceKind
{
Merge,
Cram,
}
internal readonly record struct InventoryMaintenancePlan(
InventoryMaintenanceKind Kind,
uint SourceObjectId,
uint TargetObjectId,
uint Amount);
/// <summary>
/// Pure VTank StackCram planner. AutoStack always wins over AutoCram; it groups
/// by WCID, picks the lowest-burden source and a non-full target, then performs
/// exactly one retail move. AutoCram moves one direct-main-pack non-container
/// into the first side pack with room.
/// </summary>
internal static class InventoryMaintenancePlanner
{
private const uint PublicWeenieFoci = 0x00800000u;
private static readonly ISet<uint> EmptyIgnored = new HashSet<uint>();
public static InventoryMaintenancePlan? Plan(
IReadOnlyList<PluginInventoryItem> items,
uint playerObjectId,
InventorySettings settings,
ISet<uint>? ignored = null)
{
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(settings);
ignored ??= EmptyIgnored;
if (settings.AutoStack)
{
InventoryMaintenancePlan? stack = PlanStack(items, ignored);
if (stack is not null)
return stack;
}
return settings.AutoCram
? PlanCram(items, playerObjectId, ignored)
: null;
}
private static InventoryMaintenancePlan? PlanStack(
IReadOnlyList<PluginInventoryItem> items,
ISet<uint> ignored)
{
Dictionary<uint, PluginInventoryItem> byId = items.ToDictionary(
static item => item.ObjectId);
foreach (IGrouping<uint, PluginInventoryItem> group in items
.Where(item => item.ObjectId != 0u
&& item.WeenieClassId != 0u
&& item.MaximumStackSize > 1
&& item.StackSize > 0
&& !item.IsEquipped
&& !ignored.Contains(item.ObjectId))
.GroupBy(static item => item.WeenieClassId)
.OrderBy(static group => group.Key))
{
PluginInventoryItem[] ordered = group
.OrderBy(item => BurdenRank(item, byId))
.ThenBy(static item => item.ContainerSlot)
.ThenBy(static item => item.ObjectId)
.ToArray();
if (ordered.Length < 2)
continue;
PluginInventoryItem source = ordered[0];
for (int i = ordered.Length - 1; i >= 1; i--)
{
PluginInventoryItem target = ordered[i];
int free = target.MaximumStackSize - Math.Max(1, target.StackSize);
if (free <= 0)
continue;
uint amount = (uint)Math.Min(Math.Max(1, source.StackSize), free);
return new InventoryMaintenancePlan(
InventoryMaintenanceKind.Merge,
source.ObjectId,
target.ObjectId,
amount);
}
}
return null;
}
private static InventoryMaintenancePlan? PlanCram(
IReadOnlyList<PluginInventoryItem> items,
uint playerObjectId,
ISet<uint> ignored)
{
if (playerObjectId == 0u)
return null;
PluginInventoryItem source = items
.Where(item => item.ContainerObjectId == playerObjectId
&& item.WielderObjectId == 0u
&& item.ItemsCapacity <= 0
&& item.ContainersCapacity <= 0
&& (item.PublicFlags & PublicWeenieFoci) == 0u
&& !ignored.Contains(item.ObjectId))
.OrderBy(static item => item.ContainerSlot)
.ThenBy(static item => item.ObjectId)
.FirstOrDefault();
if (source.ObjectId == 0u)
return null;
Dictionary<uint, int> containedCounts = items
.Where(static item => item.ContainerObjectId != 0u)
.GroupBy(static item => item.ContainerObjectId)
.ToDictionary(static group => group.Key, static group => group.Count());
PluginInventoryItem destination = items
.Where(item => item.ContainerObjectId == playerObjectId
&& item.ItemsCapacity > 0
&& !ignored.Contains(item.ObjectId)
&& containedCounts.GetValueOrDefault(item.ObjectId)
< item.ItemsCapacity)
.OrderBy(static item => item.ContainerSlot)
.ThenBy(static item => item.ObjectId)
.FirstOrDefault();
return destination.ObjectId != 0u
? new InventoryMaintenancePlan(
InventoryMaintenanceKind.Cram,
source.ObjectId,
destination.ObjectId,
(uint)Math.Max(1, source.StackSize))
: null;
}
private static long BurdenRank(
PluginInventoryItem item,
IReadOnlyDictionary<uint, PluginInventoryItem> byId)
{
long parent = item.ContainerObjectId != 0u
&& byId.TryGetValue(item.ContainerObjectId, out PluginInventoryItem container)
? Math.Max(0, container.Burden) + 1L
: 0L;
return Math.Max(0, item.Burden) + (10_000L * parent);
}
}
/// <summary>
/// Executes one StackCram operation at a time and waits for the host's
/// authoritative inventory receipt before planning the next one.
/// </summary>
internal sealed class InventoryMaintenanceController
{
private const int RetailAbandonAttempts = 80;
private readonly IPluginHost _host;
private readonly InventorySettings _settings;
private readonly Dictionary<(uint Source, uint Target), int> _attempts = [];
private readonly HashSet<uint> _ignored = [];
private InventoryMaintenancePlan? _pending;
private long _observedRevision;
private double _untilScan;
public InventoryMaintenanceController(
IPluginHost host,
InventorySettings settings)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
}
public string Status { get; private set; } = "Stack/Cram idle";
/// <summary>Returns true only when StackCram owns this scheduler tick.</summary>
public bool Tick(double elapsedSeconds, bool canAct)
{
IItemAutomation commands = _host.Automation.Items;
ObserveCompletion(commands);
if (_pending is not null)
{
if (commands.IsBusy)
return true;
// Older hosts may implement the command but not receipts. The
// canonical host always publishes one before clearing Busy.
_pending = null;
}
if (!canAct || !_host.Automation.IsAvailable || !commands.IsAvailable)
return false;
if (!_settings.AutoStack && !_settings.AutoCram)
{
Status = "Stack/Cram disabled";
return false;
}
if (commands.IsBusy)
return false;
_untilScan -= Math.Max(0d, elapsedSeconds);
if (_untilScan > 0d)
return false;
_untilScan = Math.Max(0.05d, _settings.ScanIntervalSeconds);
IReadOnlyList<PluginInventoryItem> inventory = commands.CaptureOwnedItems();
_ignored.RemoveWhere(id => !inventory.Any(item => item.ObjectId == id));
InventoryMaintenancePlan? plan = InventoryMaintenancePlanner.Plan(
inventory,
_host.Automation.Character.ObjectId,
_settings,
_ignored);
if (plan is not { } next)
{
Status = "Stack/Cram idle";
return false;
}
PluginItemCommandResult result = next.Kind == InventoryMaintenanceKind.Merge
? commands.Merge(next.SourceObjectId, next.TargetObjectId, next.Amount)
: commands.MoveToContainer(
next.SourceObjectId,
next.TargetObjectId,
next.Amount);
if (!result.Accepted)
{
Status = $"Stack/Cram waiting: {result.Status}";
return result.Status == PluginItemCommandStatus.Busy;
}
_pending = next;
Status = next.Kind == InventoryMaintenanceKind.Merge
? "Stacking items"
: "Moving an item to a side pack";
return true;
}
public void Reset()
{
_pending = null;
_attempts.Clear();
_ignored.Clear();
_untilScan = 0d;
Status = "Stack/Cram idle";
}
private void ObserveCompletion(IItemAutomation commands)
{
PluginInventoryCompletion completion = commands.LastInventoryCompletion;
if (completion.Revision == 0 || completion.Revision == _observedRevision)
return;
_observedRevision = completion.Revision;
if (_pending is not { } pending
|| completion.SourceObjectId != pending.SourceObjectId)
{
return;
}
if (!completion.IsSuccess)
{
var key = (pending.SourceObjectId, pending.TargetObjectId);
int attempts = _attempts.GetValueOrDefault(key) + 1;
_attempts[key] = attempts;
Status = $"Stack/Cram failed (0x{completion.WeenieError:X})";
if (attempts > RetailAbandonAttempts)
{
_ignored.Add(pending.SourceObjectId);
_ignored.Add(pending.TargetObjectId);
_host.Automation.Chat.PostSystemMessage(
"[MossTank] Abandoned trying to stack/cram two bugged items.");
}
}
else
{
_attempts.Remove((pending.SourceObjectId, pending.TargetObjectId));
}
_pending = null;
_untilScan = 0d;
}
}

View file

@ -0,0 +1,140 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal readonly record struct ItemManaRechargePlan(
uint ChargeObjectId,
uint TargetObjectId,
string ChargeName,
string TargetName,
int CurrentMana,
int MaximumMana);
internal static class ItemManaRechargePlanner
{
private const uint ManaStoneItemType = 0x00080000u;
public static ItemManaRechargePlan? Plan(
IReadOnlyList<PluginInventoryItem> inventory,
ISet<string> consumableNames,
int thresholdPercent)
{
ArgumentNullException.ThrowIfNull(inventory);
ArgumentNullException.ThrowIfNull(consumableNames);
int threshold = Math.Clamp(thresholdPercent, 0, 99);
PluginInventoryItem charge = inventory
.Where(item => (item.ItemType & ManaStoneItemType) != 0u
&& consumableNames.Contains(item.Name)
&& !item.IsEquipped)
.Where(static item => item.ItemCurrentMana > 0)
.OrderBy(static item => item.Name, StringComparer.Ordinal)
.ThenBy(static item => item.ObjectId)
.FirstOrDefault();
if (charge.ObjectId == 0u)
return null;
PluginInventoryItem target = inventory
.Where(item => item.IsEquipped
&& item.ItemMaximumMana > 0
&& 100L * Math.Max(0, item.ItemCurrentMana)
/ item.ItemMaximumMana < threshold)
.OrderBy(item => 100d * Math.Max(0, item.ItemCurrentMana)
/ item.ItemMaximumMana)
.ThenBy(static item => item.ObjectId)
.FirstOrDefault();
return target.ObjectId == 0u
? null
: new ItemManaRechargePlan(
charge.ObjectId,
target.ObjectId,
charge.Name,
target.Name,
target.ItemCurrentMana,
target.ItemMaximumMana);
}
}
internal sealed class ItemManaRechargeController
{
private readonly IPluginHost _host;
private readonly InventorySettings _settings;
private readonly CombatSettings _profiles;
private ItemManaRechargePlan? _pending;
private long _observedCompletion;
public ItemManaRechargeController(
IPluginHost host,
InventorySettings settings,
CombatSettings profiles)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_profiles = profiles ?? throw new ArgumentNullException(nameof(profiles));
}
public string Status { get; private set; } = "Worn mana ready";
public bool Tick(bool canAct)
{
IItemAutomation items = _host.Automation.Items;
ObserveCompletion(items);
if (_pending is not null)
{
if (items.IsBusy)
return true;
_pending = null;
}
if (!canAct
|| !_settings.RefillWornMana
|| !_host.Automation.IsAvailable
|| !items.IsAvailable
|| items.IsBusy)
{
return false;
}
ItemManaRechargePlan? plan = ItemManaRechargePlanner.Plan(
items.CaptureOwnedItems(),
_profiles.ConsumableNames,
_settings.RefillWornManaPercent);
if (plan is not { } next)
{
Status = "Worn mana ready";
return false;
}
PluginItemCommandResult result = items.Apply(
next.ChargeObjectId,
next.TargetObjectId);
if (!result.Accepted)
{
Status = $"Mana refill waiting: {result.Status}";
return result.Status == PluginItemCommandStatus.Busy;
}
_pending = next;
Status = $"Refilling {next.TargetName} ({next.CurrentMana}/{next.MaximumMana})";
return true;
}
public void Reset()
{
_pending = null;
Status = "Worn mana ready";
}
private void ObserveCompletion(IItemAutomation items)
{
PluginItemUseCompletion completion = items.LastCompletion;
if (completion.Revision == 0 || completion.Revision == _observedCompletion)
return;
_observedCompletion = completion.Revision;
if (_pending is not { } pending
|| completion.SourceObjectId != pending.ChargeObjectId)
{
return;
}
Status = completion.IsSuccess
? $"Refilled {pending.TargetName}"
: $"Mana refill failed (0x{completion.WeenieError:X})";
_pending = null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,656 @@
using System.Text.RegularExpressions;
using AcDream.Plugin.Abstractions;
using AcDream.Plugins.MossTank.Expressions;
namespace AcDream.Plugins.MossTank;
internal enum MetaConditionKind
{
Never,
Always,
All,
Any,
ChatMessage,
PackSlotsLessThanOrEqual,
SecondsInStateGreaterThanOrEqual,
NavigationRouteEmpty,
CharacterDeath,
AnyVendorOpen,
VendorClosed,
InventoryItemCountLessThanOrEqual,
InventoryItemCountGreaterThanOrEqual,
MonsterNameCountWithinDistance,
MonsterPriorityCountWithinDistance,
NeedToBuff,
NoMonstersWithinDistance,
LandblockEquals,
LandcellEquals,
PortalspaceEntered,
PortalspaceExited,
Not,
PersistentSecondsInStateGreaterThanOrEqual,
TimeLeftOnSpellGreaterThanOrEqual,
BurdenPercentGreaterThanOrEqual,
DistanceFromAnyRoutePointGreaterThanOrEqual,
Expression,
ChatMessageCapture,
}
internal enum MetaActionKind
{
None,
SetMetaState,
ChatCommand,
All,
LoadEmbeddedNavigationRoute,
CallMetaState,
ReturnFromCall,
ExpressionAction,
ChatExpression,
SetWatchdog,
ClearWatchdog,
GetVtankOption,
SetVtankOption,
CreateView,
DestroyView,
DestroyAllViews,
}
internal sealed class MetaCondition
{
public MetaConditionKind Kind { get; set; } = MetaConditionKind.Always;
public string Text { get; set; } = string.Empty;
public string SecondaryText { get; set; } = string.Empty;
public double Number { get; set; }
public double SecondaryNumber { get; set; }
public double TertiaryNumber { get; set; }
public List<MetaCondition> Children { get; set; } = [];
public static MetaCondition Always() => new() { Kind = MetaConditionKind.Always };
}
internal sealed class MetaAction
{
public MetaActionKind Kind { get; set; } = MetaActionKind.None;
public string Text { get; set; } = string.Empty;
public string SecondaryText { get; set; } = string.Empty;
public double Number { get; set; }
public double SecondaryNumber { get; set; }
public List<MetaAction> Children { get; set; } = [];
}
internal sealed class MetaRule
{
public Guid Id { get; set; } = Guid.NewGuid();
public string State { get; set; } = MetaEngine.DefaultState;
public MetaCondition Condition { get; set; } = MetaCondition.Always();
public MetaAction Action { get; set; } = new();
public bool Enabled { get; set; } = true;
}
internal sealed class MetaProfile
{
public List<MetaRule> Rules { get; set; } = [];
}
/// <summary>Bridges engine behavior to the already-owned MossTank controllers.</summary>
internal sealed class MetaServices
{
public Func<bool> IsNavigationRouteEmpty { get; init; } = static () => true;
public Func<bool> NeedsBuff { get; init; } = static () => false;
public Func<double> DistanceFromAnyRoutePoint { get; init; } =
static () => double.PositiveInfinity;
public Func<int, double, int> CountMonstersByPriority { get; init; } =
static (_, _) => 0;
public Action<string> LoadEmbeddedNavigationRoute { get; init; } = static _ => { };
public Func<string, ExpressionValue> GetOption { get; init; } =
static _ => ExpressionValue.Zero;
public Func<string, ExpressionValue, bool> SetOption { get; init; } =
static (_, _) => false;
public Func<string, string, bool> CreateView { get; init; } =
static (_, _) => false;
public Func<string, bool> DestroyView { get; init; } = static _ => false;
public Action DestroyAllViews { get; init; } = static () => { };
}
/// <summary>
/// VTank's ordered Meta engine: state-local rules fire once per state entry,
/// actions may continue the same pass, and transitions/calls stop the pass.
/// </summary>
internal sealed class MetaEngine
{
public const string DefaultState = "Default";
public const double DecisionIntervalSeconds = 0.293d;
public const int MaximumCallDepth = 10_000;
private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100);
private readonly IPluginHost _host;
private readonly MossTankExpressionRuntime _expressions;
private readonly MetaServices _services;
private readonly HashSet<Guid> _fired = [];
private readonly Stack<string> _callStack = [];
private readonly List<PluginChatMessage> _chatBatch = [];
private MetaProfile _profile;
private double _decisionAccumulator;
private double _stateSeconds;
private double _persistentStateSeconds;
private ulong _chatSequence;
private bool _wasPortalSpace;
private bool _wasDead;
private bool _portalEntered;
private bool _portalExited;
private bool _deathEdge;
private Watchdog? _watchdog;
private string _status = "Meta disabled.";
public MetaEngine(
IPluginHost host,
MossTankExpressionRuntime expressions,
MetaProfile profile,
MetaServices? services = null)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_expressions = expressions ?? throw new ArgumentNullException(nameof(expressions));
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
_services = services ?? new MetaServices();
_wasPortalSpace = host.Automation.Navigation.Snapshot.IsPortalSpace;
_wasDead = IsDead();
}
public bool Enabled { get; private set; }
public string CurrentState { get; private set; } = DefaultState;
public string Status => _status;
public int CallDepth => _callStack.Count;
public int FiredRuleCount => _fired.Count;
public IReadOnlyCollection<string> States => _profile.Rules
.Select(static rule => NormalizeState(rule.State))
.Append(DefaultState)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
public void SetEnabled(bool enabled)
{
if (Enabled == enabled)
return;
Enabled = enabled;
if (enabled)
{
_stateSeconds = 0d;
_decisionAccumulator = DecisionIntervalSeconds;
_status = $"Meta running: {CurrentState}.";
}
else
{
_status = "Meta disabled.";
_watchdog = null;
}
}
/// <summary>
/// Ends the complete VTank meta-session lifetime. A graphical plugin may
/// survive logout and reconnect, but call stacks, once-per-entry receipts,
/// chat cursors, portal/death edges and state timers must not cross that
/// boundary into the next character session.
/// </summary>
public void ResetSession()
{
Enabled = false;
CurrentState = DefaultState;
_fired.Clear();
_callStack.Clear();
_chatBatch.Clear();
_decisionAccumulator = 0d;
_stateSeconds = 0d;
_persistentStateSeconds = 0d;
_chatSequence = 0u;
_wasPortalSpace = _host.Automation.Navigation.Snapshot.IsPortalSpace;
_wasDead = IsDead();
_portalEntered = false;
_portalExited = false;
_deathEdge = false;
_watchdog = null;
_status = "Meta disabled.";
}
public void ReplaceProfile(MetaProfile profile)
{
_profile = profile ?? throw new ArgumentNullException(nameof(profile));
Transition(DefaultState);
}
public void Transition(string state)
{
CurrentState = NormalizeState(state);
_fired.Clear();
_stateSeconds = 0d;
_persistentStateSeconds = 0d;
_watchdog = null;
_status = $"Meta transitioned to {CurrentState}.";
}
public void OnTick(double elapsedSeconds)
{
if (elapsedSeconds < 0d || !double.IsFinite(elapsedSeconds))
throw new ArgumentOutOfRangeException(nameof(elapsedSeconds));
_expressions.OnTick(elapsedSeconds);
CaptureEdgesAndChat();
if (!Enabled)
return;
_stateSeconds += elapsedSeconds;
_persistentStateSeconds += elapsedSeconds;
_decisionAccumulator += elapsedSeconds;
UpdateWatchdog(elapsedSeconds);
if (_decisionAccumulator < DecisionIntervalSeconds)
return;
_decisionAccumulator %= DecisionIntervalSeconds;
EvaluatePass();
_portalEntered = false;
_portalExited = false;
_deathEdge = false;
_chatBatch.Clear();
}
public void EvaluatePass()
{
if (!Enabled)
return;
if (WatchdogExpired())
{
if (_callStack.Count >= MaximumCallDepth)
{
DisableWithError("Meta Error: Call stack overflow (watchdog loop?).");
return;
}
string target = _watchdog!.Value.State;
_callStack.Push(CurrentState);
Transition(target);
_status = $"Meta watchdog expired; calling {target}.";
return;
}
MetaRule[] rules = _profile.Rules.Where(rule =>
rule.Enabled
&& NormalizeState(rule.State).Equals(
CurrentState,
StringComparison.OrdinalIgnoreCase)).ToArray();
foreach (MetaRule rule in rules)
{
if (_fired.Contains(rule.Id) || !EvaluateCondition(rule.Condition))
continue;
_fired.Add(rule.Id);
_status = $"Meta executing {Describe(rule.Action)}.";
bool continuePass;
try
{
continuePass = ExecuteAction(rule.Action);
}
catch (Exception error)
{
_status = $"Meta action failed: {error.Message}";
_host.Log.Error(_status, error);
continuePass = false;
}
if (!continuePass)
break;
}
}
/// <summary>Command-only test hook matching VTank's /vt fakedeath.</summary>
internal void TriggerFakeDeath()
{
_deathEdge = true;
if (Enabled)
EvaluatePass();
_deathEdge = false;
}
private bool EvaluateCondition(MetaCondition condition) => condition.Kind switch
{
MetaConditionKind.Never => false,
MetaConditionKind.Always => true,
MetaConditionKind.All => condition.Children.All(EvaluateCondition),
MetaConditionKind.Any => condition.Children.Any(EvaluateCondition),
MetaConditionKind.Not => condition.Children.Count != 0
&& !EvaluateCondition(condition.Children[0]),
MetaConditionKind.ChatMessage => ChatMatch(condition, capture: false),
MetaConditionKind.ChatMessageCapture => ChatMatch(condition, capture: true),
MetaConditionKind.PackSlotsLessThanOrEqual =>
EvaluateNumber("getfreeitemslots[]") <= condition.Number,
MetaConditionKind.SecondsInStateGreaterThanOrEqual =>
_stateSeconds >= condition.Number,
MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual =>
_persistentStateSeconds >= condition.Number,
MetaConditionKind.NavigationRouteEmpty => _services.IsNavigationRouteEmpty(),
MetaConditionKind.CharacterDeath => _deathEdge,
MetaConditionKind.AnyVendorOpen => hostItems().ActiveVendorObjectId != 0u,
MetaConditionKind.VendorClosed => hostItems().ActiveVendorObjectId == 0u,
MetaConditionKind.InventoryItemCountLessThanOrEqual =>
InventoryCount(condition.Text) <= condition.Number,
MetaConditionKind.InventoryItemCountGreaterThanOrEqual =>
InventoryCount(condition.Text) >= condition.Number,
MetaConditionKind.MonsterNameCountWithinDistance =>
MonsterCount(condition.Text, condition.SecondaryNumber) >= condition.Number,
MetaConditionKind.MonsterPriorityCountWithinDistance =>
_services.CountMonstersByPriority(
checked((int)condition.TertiaryNumber),
condition.SecondaryNumber) >= condition.Number,
MetaConditionKind.NeedToBuff => _services.NeedsBuff(),
MetaConditionKind.NoMonstersWithinDistance =>
_host.Automation.Combat.CaptureHostileTargets(
checked((float)condition.Number)).Count == 0,
MetaConditionKind.LandblockEquals =>
(_host.Automation.Navigation.Snapshot.Position.CellId & 0xFFFF0000u)
== unchecked((uint)checked((int)condition.Number)),
MetaConditionKind.LandcellEquals =>
_host.Automation.Navigation.Snapshot.Position.CellId
== unchecked((uint)checked((int)condition.Number)),
MetaConditionKind.PortalspaceEntered => _portalEntered,
MetaConditionKind.PortalspaceExited => _portalExited,
MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual =>
SpellTimeLeft(condition) >= condition.SecondaryNumber,
MetaConditionKind.BurdenPercentGreaterThanOrEqual =>
EvaluateNumber("getcharburden[]") >= condition.Number,
MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual =>
_services.DistanceFromAnyRoutePoint() >= condition.Number,
MetaConditionKind.Expression =>
_expressions.Evaluate(condition.Text).IsTruthy,
_ => false,
};
private bool ExecuteAction(MetaAction action)
{
switch (action.Kind)
{
case MetaActionKind.None:
return true;
case MetaActionKind.SetMetaState:
Transition(action.Text);
return false;
case MetaActionKind.ChatCommand:
_host.Automation.Chat.Submit(action.Text);
return true;
case MetaActionKind.All:
foreach (MetaAction child in action.Children)
{
if (!ExecuteAction(child))
return false;
}
return true;
case MetaActionKind.LoadEmbeddedNavigationRoute:
_services.LoadEmbeddedNavigationRoute(action.Text);
return true;
case MetaActionKind.CallMetaState:
if (_callStack.Count >= MaximumCallDepth)
{
DisableWithError("Meta Error: Call stack overflow (recursive call loop?).");
return false;
}
_callStack.Push(string.IsNullOrWhiteSpace(action.SecondaryText)
? CurrentState
: NormalizeState(action.SecondaryText));
Transition(action.Text);
return false;
case MetaActionKind.ReturnFromCall:
if (_callStack.Count == 0)
{
DisableWithError("Meta Error: Call stack underflow, cannot return.");
return false;
}
Transition(_callStack.Pop());
return false;
case MetaActionKind.ExpressionAction:
_expressions.Evaluate(action.Text);
return true;
case MetaActionKind.ChatExpression:
ExpressionValue result = _expressions.Evaluate(action.Text);
if (result.ToDisplayString().Length != 0)
_host.Automation.Chat.Submit(result.ToDisplayString());
return true;
case MetaActionKind.SetWatchdog:
SetWatchdog(
action.Text,
action.Number <= 0d ? 5d : action.Number,
action.SecondaryNumber <= 0d ? 10d : action.SecondaryNumber);
return true;
case MetaActionKind.ClearWatchdog:
_watchdog = null;
return true;
case MetaActionKind.GetVtankOption:
_expressions.State.Set(
ExpressionVariableScope.Session,
string.IsNullOrWhiteSpace(action.SecondaryText)
? "option"
: action.SecondaryText,
_services.GetOption(action.Text));
return true;
case MetaActionKind.SetVtankOption:
return _services.SetOption(
action.Text,
_expressions.Evaluate(action.SecondaryText));
case MetaActionKind.CreateView:
return _services.CreateView(action.Text, action.SecondaryText);
case MetaActionKind.DestroyView:
return _services.DestroyView(action.Text);
case MetaActionKind.DestroyAllViews:
_services.DestroyAllViews();
return true;
default:
return false;
}
}
private void CaptureEdgesAndChat()
{
bool portal = _host.Automation.Navigation.Snapshot.IsPortalSpace;
_portalEntered |= !_wasPortalSpace && portal;
_portalExited |= _wasPortalSpace && !portal;
_wasPortalSpace = portal;
bool dead = IsDead();
_deathEdge |= !_wasDead && dead;
_wasDead = dead;
IReadOnlyList<PluginChatMessage> messages =
_host.Automation.Chat.CaptureMessages(_chatSequence);
foreach (PluginChatMessage message in messages)
{
_chatBatch.Add(message);
_chatSequence = Math.Max(_chatSequence, message.Sequence);
}
}
private bool ChatMatch(MetaCondition condition, bool capture)
{
Regex regex;
try
{
regex = new Regex(
condition.Text,
RegexOptions.CultureInvariant,
RegexTimeout);
}
catch (ArgumentException)
{
return false;
}
HashSet<int>? acceptedKinds = ParseKinds(condition.SecondaryText);
foreach (PluginChatMessage message in _chatBatch)
{
if (acceptedKinds is not null && !acceptedKinds.Contains(message.Kind))
continue;
Match match = regex.Match(message.Text);
if (!match.Success)
continue;
if (capture)
{
foreach (string name in regex.GetGroupNames())
{
Group group = match.Groups[name];
string variable = "capturegroup_" + name;
if (group.Success)
{
_expressions.State.Set(
ExpressionVariableScope.Session,
variable,
ExpressionValue.String(group.Value));
}
else
{
_expressions.State.Clear(
ExpressionVariableScope.Session,
variable);
}
}
_expressions.State.Set(
ExpressionVariableScope.Session,
"capturecolor",
ExpressionValue.Number(message.Kind));
}
return true;
}
return false;
}
private static HashSet<int>? ParseKinds(string source)
{
if (string.IsNullOrWhiteSpace(source))
return null;
var result = new HashSet<int>();
foreach (string part in source.Split(';', StringSplitOptions.RemoveEmptyEntries))
{
if (!int.TryParse(part.Trim(), out int kind))
return [];
result.Add(kind);
}
return result;
}
private double InventoryCount(string name)
{
string escaped = name.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("'", "\\'", StringComparison.Ordinal);
return EvaluateNumber($"getitemcountininventorybyname['{escaped}']");
}
private int MonsterCount(string pattern, double distance)
{
Regex regex;
try
{
regex = new Regex(
pattern,
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
RegexTimeout);
}
catch (ArgumentException)
{
return 0;
}
return _host.Automation.Combat.CaptureHostileTargets(
checked((float)distance)).Count(target => regex.IsMatch(target.Name));
}
private double SpellTimeLeft(MetaCondition condition)
{
uint spellId = condition.Number > 0d
? checked((uint)condition.Number)
: _host.Automation.Spells.KnownSelfBuffs
.Concat(_host.Automation.Spells.KnownCombatSpells)
.FirstOrDefault(spell => spell.Name.Equals(
condition.Text,
StringComparison.OrdinalIgnoreCase)).SpellId;
foreach (PluginActiveEnchantment enchantment in
_host.Automation.Character.ActiveEnchantments)
{
if (enchantment.SpellId == spellId)
return enchantment.SecondsRemaining;
}
return 0d;
}
private double EvaluateNumber(string source) =>
_expressions.Evaluate(source).AsNumber(source);
private IItemAutomation hostItems() => _host.Automation.Items;
private bool IsDead()
{
ICharacterInfo character = _host.Automation.Character;
return character.IsInWorld
&& character.MaxHealth > 0u
&& character.CurrentHealth == 0u;
}
private void SetWatchdog(string state, double rangeMeters, double seconds)
{
PluginNavigationPosition position =
_host.Automation.Navigation.Snapshot.Position;
_watchdog = new Watchdog(
NormalizeState(state),
Math.Max(0d, rangeMeters),
Math.Max(0.001d, seconds),
0d,
0d,
Enumerable.Repeat(position, 10).ToArray());
}
private void UpdateWatchdog(double elapsedSeconds)
{
if (_watchdog is not Watchdog watchdog)
return;
watchdog = watchdog with
{
TotalSeconds = watchdog.TotalSeconds + elapsedSeconds,
SampleSeconds = watchdog.SampleSeconds + elapsedSeconds,
};
double interval = watchdog.TimeSpanSeconds / 10d;
if (watchdog.SampleSeconds >= interval)
{
int index = ((int)Math.Floor(watchdog.TotalSeconds / interval)) % 10;
watchdog.Samples[index] = _host.Automation.Navigation.Snapshot.Position;
watchdog = watchdog with { SampleSeconds = watchdog.SampleSeconds % interval };
}
_watchdog = watchdog;
}
private bool WatchdogExpired()
{
if (_watchdog is not Watchdog watchdog
|| watchdog.TotalSeconds < watchdog.TimeSpanSeconds)
{
return false;
}
PluginNavigationPosition current =
_host.Automation.Navigation.Snapshot.Position;
return watchdog.Samples.All(sample =>
sample.HorizontalDistanceMeters(current) <= watchdog.RangeMeters);
}
private void DisableWithError(string message)
{
Enabled = false;
_status = message + " Meta disabled.";
_host.Automation.Chat.PostSystemMessage(_status);
_host.Log.Error(_status);
}
private static string NormalizeState(string? state) =>
string.IsNullOrWhiteSpace(state) ? DefaultState : state.Trim();
private static string Describe(MetaAction action) => action.Kind switch
{
MetaActionKind.SetMetaState => $"Set Meta State {action.Text}",
MetaActionKind.CallMetaState => $"Call Meta State {action.Text}",
MetaActionKind.ChatCommand => $"Chat {action.Text}",
_ => action.Kind.ToString(),
};
private readonly record struct Watchdog(
string State,
double RangeMeters,
double TimeSpanSeconds,
double TotalSeconds,
double SampleSeconds,
PluginNavigationPosition[] Samples);
}

View file

@ -0,0 +1,97 @@
using System.Security.Cryptography;
using System.Text;
using System.Xml.Linq;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Owns VTank Meta-created views independently from the macro window. The
/// official implementation replaces duplicate names and permits five normal
/// entries (including its historical sixth-entry boundary quirk).
/// </summary>
internal sealed class MetaViewManager
{
private const int OfficialViewLimit = 5;
private readonly IPluginHost _host;
private readonly Dictionary<string, IDisposable> _views =
new(StringComparer.Ordinal);
public MetaViewManager(IPluginHost host) =>
_host = host ?? throw new ArgumentNullException(nameof(host));
public int Count => _views.Count;
public bool Create(string name, string markup)
{
if (!_host.HasUi || string.IsNullOrEmpty(name) || string.IsNullOrEmpty(markup))
return false;
// bw.a(string,string) checks Count > 5 before duplicate replacement.
// Preserve that observable VTank quirk for imported Meta profiles.
if (_views.Count > OfficialViewLimit)
return false;
try
{
XElement root = XDocument.Parse(markup).Root
?? throw new InvalidDataException("View markup has no root element.");
if (!root.Name.LocalName.Equals("panel", StringComparison.OrdinalIgnoreCase))
return false;
}
catch (Exception error) when (error is InvalidDataException or System.Xml.XmlException)
{
_host.Log.Warn($"MossTank Meta view '{name}' is invalid: {error.Message}");
return false;
}
Destroy(name);
IDisposable token = _host.Ui.RegisterPanelContent(
new PluginPanelDescriptor(WindowId(name), name)
{
IconText = Initials(name),
StartVisible = true,
ShowInSidePanel = true,
},
markup,
MetaViewBinding.Instance);
_views.Add(name, token);
return true;
}
public bool Destroy(string name)
{
if (!_views.Remove(name, out IDisposable? registration))
return false;
registration.Dispose();
return true;
}
public void DestroyAll()
{
IDisposable[] registrations = _views.Values.ToArray();
_views.Clear();
foreach (IDisposable registration in registrations)
registration.Dispose();
}
private static string WindowId(string name)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(name));
return "meta-" + Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant();
}
private static string Initials(string name)
{
string[] words = name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (words.Length == 0)
return "M";
return string.Concat(words.Take(2).Select(static word => word[0])).ToUpperInvariant();
}
private sealed class MetaViewBinding
{
internal static MetaViewBinding Instance { get; } = new();
public bool WindowAvailable => true;
}
}

View file

@ -0,0 +1,608 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace AcDream.Plugins.MossTank;
internal enum MonsterValueKind
{
Number,
Text,
Boolean,
}
/// <summary>
/// One value in VTank's monster-list expression language. Unlike meta
/// expressions, monster expressions have a real boolean type and require both
/// operands of a comparison to have the same type.
/// </summary>
internal readonly record struct MonsterValue
{
private MonsterValue(
MonsterValueKind kind,
double number,
string? text,
bool boolean)
{
Kind = kind;
Number = number;
Text = text ?? string.Empty;
Boolean = boolean;
}
public MonsterValueKind Kind { get; }
public double Number { get; }
public string Text { get; }
public bool Boolean { get; }
public static MonsterValue FromNumber(double value) =>
new(MonsterValueKind.Number, value, null, false);
public static MonsterValue FromText(string value) =>
new(MonsterValueKind.Text, 0d, value, false);
public static MonsterValue FromBoolean(bool value) =>
new(MonsterValueKind.Boolean, 0d, null, value);
public override string ToString() => Kind switch
{
MonsterValueKind.Number => Number.ToString(CultureInfo.InvariantCulture),
MonsterValueKind.Text => Text,
MonsterValueKind.Boolean => Boolean ? "true" : "false",
_ => string.Empty,
};
}
/// <summary>Live values exposed by VTank's <c>/vt listmonstervariables</c>.</summary>
internal readonly record struct MonsterExpressionContext(
string Name,
uint TypeId,
string Species,
int MaximumHealth,
float Range,
bool HasShield,
string MetaState,
Func<string, MonsterValue?>? Setting = null)
{
internal bool TryResolve(string token, out MonsterValue value)
{
if (token.Equals("true", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromBoolean(true);
return true;
}
if (token.Equals("false", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromBoolean(false);
return true;
}
if (token.Equals("name", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromText(Name);
return true;
}
if (token.Equals("typeid", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromNumber(TypeId);
return true;
}
if (token.Equals("species", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromText(Species);
return true;
}
if (token.Equals("maxhp", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromNumber(MaximumHealth);
return true;
}
if (token.Equals("range", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromNumber(Range);
return true;
}
if (token.Equals("hasshield", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromBoolean(HasShield);
return true;
}
if (token.Equals("metastate", StringComparison.OrdinalIgnoreCase))
{
value = MonsterValue.FromText(MetaState);
return true;
}
// VTank documents setting names as case-sensitive even though the
// built-in monster variables and string comparisons are not.
const string settingPrefix = "setting_";
if (token.StartsWith(settingPrefix, StringComparison.Ordinal)
&& Setting?.Invoke(token[settingPrefix.Length..]) is { } setting)
{
value = setting;
return true;
}
value = default;
return false;
}
}
internal sealed class MonsterExpressionException(string message)
: FormatException(message);
/// <summary>
/// Immutable compiled VTank monster-list expression. The lexer deliberately
/// has no quoted strings: VTank strings are runs of letters/spaces and use a
/// backslash to escape every operator, digit, or punctuation character.
/// </summary>
internal sealed class MonsterExpression
{
private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(25);
private readonly Node _root;
private MonsterExpression(string source, Node root)
{
Source = source;
_root = root;
}
public string Source { get; }
public bool IsDynamic => _root.IsDynamic;
public static MonsterExpression Compile(string source)
{
ArgumentNullException.ThrowIfNull(source);
string normalized = source.Trim();
if (normalized.Length == 0)
throw new MonsterExpressionException("Monster expression is empty.");
var parser = new Parser(normalized);
Node root = parser.Parse();
return new MonsterExpression(normalized, root);
}
public bool TryEvaluate(
in MonsterExpressionContext context,
out MonsterValue value,
out string? error)
{
try
{
value = _root.Evaluate(context);
error = null;
return true;
}
catch (Exception ex) when (ex is MonsterExpressionException
or RegexMatchTimeoutException
or ArgumentException
or OverflowException
or DivideByZeroException)
{
value = default;
error = ex.Message;
return false;
}
}
public bool IsMatch(in MonsterExpressionContext context, out string? error)
{
if (!TryEvaluate(context, out MonsterValue result, out error))
return false;
return result.Kind switch
{
MonsterValueKind.Boolean => result.Boolean,
MonsterValueKind.Text => string.Equals(
result.Text.Trim(),
context.Name.Trim(),
StringComparison.OrdinalIgnoreCase),
_ => false,
};
}
private abstract class Node(bool isDynamic)
{
internal bool IsDynamic { get; } = isDynamic;
internal abstract MonsterValue Evaluate(in MonsterExpressionContext context);
}
private sealed class NumberNode(double value) : Node(false)
{
internal override MonsterValue Evaluate(in MonsterExpressionContext context) =>
MonsterValue.FromNumber(value);
}
private sealed class AtomNode(string token) : Node(IsDynamicToken(token))
{
internal override MonsterValue Evaluate(in MonsterExpressionContext context) =>
context.TryResolve(token, out MonsterValue value)
? value
: MonsterValue.FromText(token.Trim());
private static bool IsDynamicToken(string value) =>
value.Equals("range", StringComparison.OrdinalIgnoreCase)
|| value.Equals("hasshield", StringComparison.OrdinalIgnoreCase)
|| value.Equals("metastate", StringComparison.OrdinalIgnoreCase)
|| value.StartsWith("setting_", StringComparison.Ordinal);
}
private sealed class BinaryNode(TokenKind operation, Node left, Node right)
: Node(left.IsDynamic || right.IsDynamic)
{
internal override MonsterValue Evaluate(in MonsterExpressionContext context)
{
// VTank's boolean operators are short-circuiting in practice; this
// also keeps an invalid right branch from poisoning a decided rule.
MonsterValue lhs = left.Evaluate(context);
if (operation == TokenKind.And)
{
bool l = RequireBoolean(lhs, "&&");
return !l
? MonsterValue.FromBoolean(false)
: MonsterValue.FromBoolean(
RequireBoolean(right.Evaluate(context), "&&"));
}
if (operation == TokenKind.Or)
{
bool l = RequireBoolean(lhs, "||");
return l
? MonsterValue.FromBoolean(true)
: MonsterValue.FromBoolean(
RequireBoolean(right.Evaluate(context), "||"));
}
MonsterValue rhs = right.Evaluate(context);
return operation switch
{
TokenKind.Modulo => MonsterValue.FromNumber(
(long)RequireNumber(lhs, "%") % (long)RequireNonZero(rhs, "%")),
TokenKind.Divide => MonsterValue.FromNumber(
RequireNumber(lhs, "/") / RequireNonZero(rhs, "/")),
TokenKind.Multiply => MonsterValue.FromNumber(
RequireNumber(lhs, "*") * RequireNumber(rhs, "*")),
TokenKind.Add => Add(lhs, rhs),
TokenKind.Subtract => MonsterValue.FromNumber(
RequireNumber(lhs, "-") - RequireNumber(rhs, "-")),
TokenKind.Regex => RegexMatch(lhs, rhs),
TokenKind.Equal => Compare(lhs, rhs, comparison => comparison == 0),
TokenKind.NotEqual => Compare(lhs, rhs, comparison => comparison != 0),
TokenKind.Greater => Compare(lhs, rhs, comparison => comparison > 0),
TokenKind.Less => Compare(lhs, rhs, comparison => comparison < 0),
TokenKind.GreaterOrEqual => Compare(lhs, rhs, comparison => comparison >= 0),
TokenKind.LessOrEqual => Compare(lhs, rhs, comparison => comparison <= 0),
_ => throw new MonsterExpressionException(
$"Unsupported monster-expression operator {operation}."),
};
}
private static MonsterValue Add(MonsterValue left, MonsterValue right)
{
RequireSameType(left, right, "+");
return left.Kind switch
{
MonsterValueKind.Number => MonsterValue.FromNumber(
left.Number + right.Number),
MonsterValueKind.Text => MonsterValue.FromText(
left.Text + right.Text),
_ => throw TypeError("+", left.Kind),
};
}
private static MonsterValue RegexMatch(
MonsterValue left,
MonsterValue right)
{
RequireSameType(left, right, "#");
if (left.Kind != MonsterValueKind.Text)
throw TypeError("#", left.Kind);
return MonsterValue.FromBoolean(Regex.IsMatch(
left.Text,
right.Text,
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant,
RegexTimeout));
}
private static MonsterValue Compare(
MonsterValue left,
MonsterValue right,
Func<int, bool> predicate)
{
RequireSameType(left, right, "comparison");
int comparison = left.Kind switch
{
MonsterValueKind.Number => left.Number.CompareTo(right.Number),
MonsterValueKind.Text => string.Compare(
left.Text,
right.Text,
StringComparison.OrdinalIgnoreCase),
MonsterValueKind.Boolean => left.Boolean.CompareTo(right.Boolean),
_ => throw TypeError("comparison", left.Kind),
};
return MonsterValue.FromBoolean(predicate(comparison));
}
private static void RequireSameType(
MonsterValue left,
MonsterValue right,
string operation)
{
if (left.Kind != right.Kind)
{
throw new MonsterExpressionException(
$"Operator {operation} requires matching operand types; "
+ $"received {left.Kind} and {right.Kind}.");
}
}
private static double RequireNumber(MonsterValue value, string operation)
{
if (value.Kind != MonsterValueKind.Number)
throw TypeError(operation, value.Kind);
return value.Number;
}
private static double RequireNonZero(MonsterValue value, string operation)
{
double number = RequireNumber(value, operation);
if (number == 0d)
throw new DivideByZeroException($"Operator {operation} divided by zero.");
return number;
}
private static bool RequireBoolean(MonsterValue value, string operation)
{
if (value.Kind != MonsterValueKind.Boolean)
throw TypeError(operation, value.Kind);
return value.Boolean;
}
private static MonsterExpressionException TypeError(
string operation,
MonsterValueKind actual) => new(
$"Operator {operation} cannot be applied to {actual}.");
}
private enum TokenKind
{
End,
Atom,
Number,
LeftParen,
RightParen,
Modulo,
Divide,
Multiply,
Add,
Subtract,
Regex,
NotEqual,
Equal,
Greater,
Less,
GreaterOrEqual,
LessOrEqual,
And,
Or,
}
private readonly record struct Token(TokenKind Kind, string Text, int Offset);
private sealed class Lexer(string source)
{
private int _offset;
internal Token Next()
{
while (_offset < source.Length && char.IsWhiteSpace(source[_offset]))
_offset++;
if (_offset >= source.Length)
return new Token(TokenKind.End, string.Empty, _offset);
int start = _offset;
char current = source[_offset];
if (TryOperator(out Token operation))
return operation;
if (char.IsDigit(current)
|| (current == '.'
&& _offset + 1 < source.Length
&& char.IsDigit(source[_offset + 1])))
{
_offset++;
while (_offset < source.Length
&& (char.IsDigit(source[_offset]) || source[_offset] == '.'))
{
_offset++;
}
string number = source[start.._offset];
if (!double.TryParse(
number,
NumberStyles.Float,
CultureInfo.InvariantCulture,
out _))
{
throw new MonsterExpressionException(
$"Invalid number '{number}' at offset {start}.");
}
return new Token(TokenKind.Number, number, start);
}
var text = new StringBuilder();
while (_offset < source.Length)
{
current = source[_offset];
if (current == '\\')
{
if (_offset + 1 >= source.Length)
{
throw new MonsterExpressionException(
$"Trailing escape at offset {_offset}.");
}
text.Append(source[_offset + 1]);
_offset += 2;
continue;
}
if (IsOperatorStart(current) || char.IsDigit(current))
break;
text.Append(current);
_offset++;
}
string atom = text.ToString().Trim();
if (atom.Length == 0)
{
throw new MonsterExpressionException(
$"Unexpected character '{source[_offset]}' at offset {_offset}; "
+ "digits and punctuation in VTank strings must be escaped.");
}
return new Token(TokenKind.Atom, atom, start);
}
private bool TryOperator(out Token token)
{
int start = _offset;
char c = source[_offset];
TokenKind kind;
int length = 1;
if (_offset + 1 < source.Length)
{
string pair = source.Substring(_offset, 2);
kind = pair switch
{
"!=" => TokenKind.NotEqual,
"==" => TokenKind.Equal,
">=" => TokenKind.GreaterOrEqual,
"<=" => TokenKind.LessOrEqual,
"&&" => TokenKind.And,
"||" => TokenKind.Or,
_ => TokenKind.End,
};
if (kind != TokenKind.End)
{
length = 2;
_offset += length;
token = new Token(kind, pair, start);
return true;
}
}
kind = c switch
{
'(' => TokenKind.LeftParen,
')' => TokenKind.RightParen,
'%' => TokenKind.Modulo,
'/' => TokenKind.Divide,
'*' => TokenKind.Multiply,
'+' => TokenKind.Add,
'-' => TokenKind.Subtract,
'#' => TokenKind.Regex,
'>' => TokenKind.Greater,
'<' => TokenKind.Less,
_ => TokenKind.End,
};
if (kind == TokenKind.End)
{
token = default;
return false;
}
_offset += length;
token = new Token(kind, c.ToString(), start);
return true;
}
private static bool IsOperatorStart(char value) =>
value is '(' or ')' or '%' or '/' or '*' or '+' or '-' or '#'
or '!' or '=' or '>' or '<' or '&' or '|';
}
private sealed class Parser
{
private readonly Lexer _lexer;
private Token _current;
internal Parser(string source)
{
_lexer = new Lexer(source);
_current = _lexer.Next();
}
internal Node Parse()
{
Node result = ParseOr();
if (_current.Kind != TokenKind.End)
{
throw new MonsterExpressionException(
$"Unexpected token '{_current.Text}' at offset {_current.Offset}.");
}
return result;
}
private Node ParseOr() => ParseLeftAssociative(ParseAnd, TokenKind.Or);
private Node ParseAnd() => ParseLeftAssociative(ParseComparison, TokenKind.And);
private Node ParseComparison() => ParseLeftAssociative(
ParseRegex,
TokenKind.NotEqual,
TokenKind.Equal,
TokenKind.Greater,
TokenKind.Less,
TokenKind.GreaterOrEqual,
TokenKind.LessOrEqual);
private Node ParseRegex() => ParseLeftAssociative(ParseSubtract, TokenKind.Regex);
private Node ParseSubtract() => ParseLeftAssociative(ParseAdd, TokenKind.Subtract);
private Node ParseAdd() => ParseLeftAssociative(ParseMultiply, TokenKind.Add);
private Node ParseMultiply() => ParseLeftAssociative(ParseDivide, TokenKind.Multiply);
private Node ParseDivide() => ParseLeftAssociative(ParseModulo, TokenKind.Divide);
private Node ParseModulo() => ParseLeftAssociative(ParsePrimary, TokenKind.Modulo);
private Node ParseLeftAssociative(
Func<Node> operand,
params TokenKind[] operations)
{
Node left = operand();
while (operations.Contains(_current.Kind))
{
TokenKind operation = _current.Kind;
Advance();
left = new BinaryNode(operation, left, operand());
}
return left;
}
private Node ParsePrimary()
{
Token token = _current;
switch (token.Kind)
{
case TokenKind.Number:
Advance();
return new NumberNode(double.Parse(
token.Text,
CultureInfo.InvariantCulture));
case TokenKind.Atom:
Advance();
return new AtomNode(token.Text);
case TokenKind.LeftParen:
Advance();
Node nested = ParseOr();
Require(TokenKind.RightParen, "Closing ')' expected");
Advance();
return nested;
default:
throw new MonsterExpressionException(
$"Operand expected at offset {token.Offset}; found '{token.Text}'.");
}
}
private void Require(TokenKind kind, string message)
{
if (_current.Kind != kind)
{
throw new MonsterExpressionException(
$"{message} at offset {_current.Offset}.");
}
}
private void Advance() => _current = _lexer.Next();
}
}

View file

@ -0,0 +1,150 @@
namespace AcDream.Plugins.MossTank;
[Flags]
internal enum MonsterActionFlags
{
None = 0,
Fester = 1 << 0,
Broadside = 1 << 1,
GravityWell = 1 << 2,
Imperil = 1 << 3,
Yield = 1 << 4,
Vulnerability = 1 << 5,
Attack = 1 << 6,
Ring = 1 << 7,
Streak = 1 << 8,
WeakeningCurse = 1 << 9,
FesteringCurse = 1 << 10,
Corruption = 1 << 11,
DestructiveCurse = 1 << 12,
Corrosion = 1 << 13,
}
internal enum MonsterDamageType
{
Auto = 0,
Slash,
Pierce,
Bludgeon,
Cold,
Fire,
Acid,
Electric,
Nether,
VoidBasic,
DrainAuto,
Harm,
None,
PlayerAuto,
Prismatic,
Random,
Fists,
Physical,
}
/// <summary>Every editable column in VTank's Monsters table.</summary>
internal sealed record MonsterRuleActions
{
public MonsterActionFlags Flags { get; init; } = MonsterActionFlags.Attack;
public int Priority { get; init; }
public MonsterDamageType DamageType { get; init; } = MonsterDamageType.Auto;
public MonsterDamageType ExtraVulnerability { get; init; } =
MonsterDamageType.Auto;
public uint WeaponObjectId { get; init; }
public uint OffhandObjectId { get; init; }
/// <summary>
/// Durable profile identity. Object ids are session-local, so a loaded
/// profile resolves this exact VTank item name back to the current object.
/// </summary>
public string WeaponName { get; init; } = string.Empty;
public string OffhandName { get; init; } = string.Empty;
public MonsterDamageType PetDamageType { get; init; } =
MonsterDamageType.PlayerAuto;
public int BoundedPriority => Math.Clamp(Priority, -1, 4);
public bool Attacks => (Flags
& (MonsterActionFlags.Attack | MonsterActionFlags.Ring)) != 0;
public bool UsesPrimaryAttack => (Flags & MonsterActionFlags.Attack) != 0;
public bool UsesRing => (Flags & MonsterActionFlags.Ring) != 0;
public bool UsesStreak => (Flags & MonsterActionFlags.Streak) != 0;
}
/// <summary>
/// One ordered VTank Monsters row. Non-default rows compile the exact VTank
/// expression grammar; an expression yielding true, or a text value equal to
/// the monster's name, matches. DEFAULT is considered only after every row.
/// </summary>
internal sealed class MonsterRule
{
private readonly MonsterExpression? _compiled;
public MonsterRule(string expression, int priority)
: this(expression, new MonsterRuleActions { Priority = priority })
{
}
public MonsterRule(string expression, MonsterRuleActions actions)
{
Expression = string.IsNullOrWhiteSpace(expression)
? "DEFAULT"
: expression.Trim();
Actions = actions ?? throw new ArgumentNullException(nameof(actions));
if (!IsDefault)
_compiled = MonsterExpression.Compile(Expression);
}
public string Expression { get; }
public MonsterRuleActions Actions { get; }
public int Priority => Actions.BoundedPriority;
public bool IsDefault => Expression.Equals(
"DEFAULT",
StringComparison.OrdinalIgnoreCase);
public bool IsDynamic => _compiled?.IsDynamic == true;
public bool Matches(
in MonsterExpressionContext context,
out string? error)
{
if (_compiled is null)
{
error = null;
return IsDefault;
}
return _compiled.IsMatch(context, out error);
}
}
internal readonly record struct ResolvedMonsterRule(
MonsterRule Rule,
string? EvaluationError)
{
public MonsterRuleActions Actions => Rule.Actions;
public int Priority => Rule.Priority;
}
internal static class MonsterRuleResolver
{
/// <summary>VTank: rows after DEFAULT are checked top-to-bottom; first match wins.</summary>
internal static ResolvedMonsterRule Resolve(
IEnumerable<MonsterRule> rules,
in MonsterExpressionContext context)
{
ArgumentNullException.ThrowIfNull(rules);
MonsterRule? fallback = null;
string? firstError = null;
foreach (MonsterRule rule in rules)
{
if (rule.IsDefault)
{
fallback ??= rule;
continue;
}
if (rule.Matches(context, out string? error))
return new ResolvedMonsterRule(rule, firstError);
firstError ??= error;
}
fallback ??= new MonsterRule("DEFAULT", 0);
return new ResolvedMonsterRule(fallback, firstError);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,477 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Independent VTank loot-profile lifecycle. Macro settings select this
/// profile by character, but its ordered rules live in their own document.
/// </summary>
internal sealed class MossTankLootProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/loot/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly IPluginHost _host;
private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
public MossTankLootProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(_index.Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
public bool BindCharacter(string? characterName)
{
string normalized = string.IsNullOrWhiteSpace(characterName)
? string.Empty
: characterName.Trim();
if (string.Equals(
normalized,
_characterName,
StringComparison.OrdinalIgnoreCase))
{
return false;
}
_characterName = normalized;
_selected = _index.SelectedByCharacter.TryGetValue(
SelectionKey(),
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
return true;
}
public bool Select(string? name)
{
string normalized = name?.Trim() ?? string.Empty;
if (!IsKnown(normalized))
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
return true;
}
public bool Create(
string? name,
bool copyCurrent,
IReadOnlyList<LootRule> current,
out string notice,
LootSettings? settings = null)
{
string normalized = name?.Trim() ?? string.Empty;
if (normalized.Length is < 1 or > 64)
{
notice = "Enter a loot profile name (1-64 characters).";
return false;
}
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in loot profile.";
return false;
}
LootProfileDocument? currentDocument = copyCurrent
? Read<LootProfileDocument>(CurrentKey())
: null;
var document = new LootProfileDocument
{
Rules = copyCurrent
? current.Select(LootRuleDocument.From).ToArray()
: [],
SalvageCombine = copyCurrent
? (settings?.SalvageCombine.Clone()
?? currentDocument?.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings())
: new VtankSalvageCombineSettings(),
UnknownBlocks = copyCurrent
? currentDocument?.UnknownBlocks ?? []
: [],
};
Write(ProfileKey(normalized, byCharacter: false), document);
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
WriteLegacyExport(_selected, document);
notice = copyCurrent
? $"Copied loot rules to {_selected}."
: $"Created loot profile {_selected}.";
return true;
}
/// <summary>Returns false when no document exists (legacy migration seam).</summary>
public bool LoadCurrent(List<LootRule> target, LootSettings? settings = null)
{
ArgumentNullException.ThrowIfNull(target);
LootProfileDocument? document = Read<LootProfileDocument>(CurrentKey());
if (document is null)
return false;
target.Clear();
foreach (LootRuleDocument rule in document.Rules ?? [])
target.Add(rule.ToRule());
if (settings is not null)
{
settings.SalvageCombine =
document.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings();
}
return true;
}
/// <summary>
/// Loads a profile for an automation job without changing the profile
/// selected in the MossTank editor. UtilityBelt's item giver has the same
/// separation: using a give profile must not replace the active loot
/// profile.
/// </summary>
public bool TryLoadNamed(string? name, List<LootRule> target)
{
ArgumentNullException.ThrowIfNull(target);
string normalized = name?.Trim() ?? string.Empty;
if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase))
normalized = normalized[..^4];
if (!IsKnown(normalized))
return false;
string canonical = CanonicalName(normalized);
string key = canonical.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(canonical, byCharacter: false);
LootProfileDocument? document = Read<LootProfileDocument>(key);
if (document is null)
return false;
target.Clear();
foreach (LootRuleDocument rule in document.Rules ?? [])
target.Add(rule.ToRule());
return true;
}
public void SaveCurrent(
IReadOnlyList<LootRule> rules,
LootSettings? settings = null)
{
LootProfileDocument? existing = Read<LootProfileDocument>(CurrentKey());
var document = new LootProfileDocument
{
Rules = rules.Select(LootRuleDocument.From).ToArray(),
SalvageCombine = settings?.SalvageCombine.Clone()
?? existing?.SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings(),
UnknownBlocks = existing?.UnknownBlocks ?? [],
};
Write(CurrentKey(), document);
WriteLegacyExport(LegacyProfileName(), document);
}
public void ClearCurrent(List<LootRule> target, LootSettings? settings = null)
{
target.Clear();
if (settings is not null)
settings.SalvageCombine = new VtankSalvageCombineSettings();
SaveCurrent(target, settings);
}
public bool TryImportLegacy(
string? name,
List<LootRule> target,
LootSettings? settings,
out string notice)
{
ArgumentNullException.ThrowIfNull(target);
string normalized = name?.Trim() ?? string.Empty;
if (normalized.EndsWith(".utl", StringComparison.OrdinalIgnoreCase))
normalized = normalized[..^4];
if (!_host.Storage.IsAvailable || normalized.Length == 0)
{
notice = "Legacy loot-profile storage is unavailable.";
return false;
}
string? key = _host.Storage.List("imports")
.Concat(_host.Storage.List("exports"))
.FirstOrDefault(candidate =>
candidate.EndsWith(".utl", StringComparison.OrdinalIgnoreCase)
&& Path.GetFileNameWithoutExtension(candidate).Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
string? source = key is null ? null : _host.Storage.ReadText(key);
if (string.IsNullOrWhiteSpace(source))
{
notice = $"VTClassic loot file '{normalized}.utl' was not found in imports.";
return false;
}
if (!VtankLootProfileSerializer.TryRead(
source,
out VtankLootProfile imported,
out string error))
{
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
var document = new LootProfileDocument
{
Rules = imported.Rules.Select(LootRuleDocument.From).ToArray(),
SalvageCombine = imported.SalvageCombine.Clone(),
UnknownBlocks = imported.UnknownBlocks.Select(
VtankLootExtraBlockDocument.From).ToArray(),
};
Write(ProfileKey(normalized, byCharacter: false), document);
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
target.Clear();
target.AddRange(imported.Rules);
if (settings is not null)
settings.SalvageCombine = imported.SalvageCombine.Clone();
WriteLegacyExport(_selected, document);
notice = $"Imported VTClassic loot profile {_selected}.";
return true;
}
private bool IsKnown(string? name) => name is not null
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
private string CanonicalName(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Names.First(entry => entry.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
private static string ProfileKey(string value, bool byCharacter)
{
string identity = (byCharacter ? "char:" : "named:")
+ value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/loot/{hash}.json";
}
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
? "_default"
: _characterName;
private T? Read<T>(string key) where T : class
{
if (!_host.Storage.IsAvailable)
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"loot",
key,
json,
error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
}
catch (Exception error)
{
_host.Log.Warn($"MossTank loot profile could not be saved: {error.Message}");
}
}
private void SaveIndex() => Write(IndexKey, _index);
private void WriteLegacyExport(string name, LootProfileDocument document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.utl",
VtankLootProfileSerializer.Write(document.ToVtankProfile()));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTClassic loot export could not be saved: {error.Message}");
}
}
private string LegacyProfileName() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_characterName)
? ByCharacter
: _characterName
: _selected;
private static string LegacyFileName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Loot" : result.ToString();
}
private sealed class IndexDocument
{
public int Version { get; set; } = 1;
public List<string> Names { get; set; } = [];
public Dictionary<string, string> SelectedByCharacter { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
private sealed class LootProfileDocument
{
public int Version { get; set; } = 2;
public LootRuleDocument[] Rules { get; set; } = [];
public VtankSalvageCombineSettings? SalvageCombine { get; set; } = new();
public VtankLootExtraBlockDocument[] UnknownBlocks { get; set; } = [];
public VtankLootProfile ToVtankProfile() => new()
{
Rules = (Rules ?? []).Select(static rule => rule.ToRule()).ToList(),
SalvageCombine = SalvageCombine?.Clone()
?? new VtankSalvageCombineSettings(),
UnknownBlocks = (UnknownBlocks ?? [])
.Select(static block => block.ToBlock())
.ToList(),
};
}
private sealed class LootRuleDocument
{
public string Name { get; set; } = "Rule";
public string Expression { get; set; } = "*";
public LootAction Action { get; set; } = LootAction.Keep;
public int KeepCount { get; set; } = 1;
public int Priority { get; set; }
public string CustomExpression { get; set; } = string.Empty;
public VtankLootRequirementDocument[] Requirements { get; set; } = [];
public static LootRuleDocument From(LootRule rule) => new()
{
Name = rule.Name,
Expression = rule.Expression,
Action = rule.Action,
KeepCount = rule.KeepCount,
Priority = rule.Priority,
CustomExpression = rule.CustomExpression,
Requirements = rule.VtankRequirements.Select(
VtankLootRequirementDocument.From).ToArray(),
};
public LootRule ToRule() => new()
{
Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(),
Expression = string.IsNullOrWhiteSpace(Expression)
? "*"
: Expression.Trim(),
Action = Action,
KeepCount = Math.Clamp(KeepCount, 0, 100000),
Priority = Math.Clamp(Priority, -1000, 1000),
CustomExpression = CustomExpression ?? string.Empty,
VtankRequirements = (Requirements ?? [])
.Select(static requirement => requirement.ToRequirement())
.ToList(),
};
}
private sealed class VtankLootRequirementDocument
{
public int Type { get; set; }
public string Payload { get; set; } = string.Empty;
public static VtankLootRequirementDocument From(
VtankLootRequirement requirement) => new()
{
Type = requirement.Type,
Payload = requirement.Payload,
};
public VtankLootRequirement ToRequirement() => new()
{
Type = Type,
Payload = Payload ?? string.Empty,
};
}
private sealed class VtankLootExtraBlockDocument
{
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
public static VtankLootExtraBlockDocument From(
VtankLootExtraBlock block) => new()
{
Type = block.Type,
Payload = block.Payload,
};
public VtankLootExtraBlock ToBlock() => new()
{
Type = Type ?? string.Empty,
Payload = Payload ?? string.Empty,
};
}
}

View file

@ -0,0 +1,286 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Independent VTank-style By-char/named Meta profile lifetime.</summary>
internal sealed class MossTankMetaProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/meta/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly IPluginHost _host;
private IndexDocument _index;
private string _character = string.Empty;
private string _selected = ByCharacter;
public MossTankMetaProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? [],
StringComparer.OrdinalIgnoreCase);
}
private List<string> Names => _index.Names ??= [];
private Dictionary<string, string> SelectedByCharacter =>
_index.SelectedByCharacter ??= new Dictionary<string, string>(
StringComparer.OrdinalIgnoreCase);
public string Selected => _selected;
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static name => name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
public bool BindCharacter(string? characterName)
{
string normalized = string.IsNullOrWhiteSpace(characterName)
? string.Empty
: characterName.Trim();
if (normalized.Equals(_character, StringComparison.OrdinalIgnoreCase))
return false;
_character = normalized;
_selected = SelectedByCharacter.TryGetValue(
CharacterKey(),
out string? selected)
&& IsKnown(selected)
? Canonical(selected)
: ByCharacter;
return true;
}
public MetaProfile LoadCurrent() =>
Read<MetaProfile>(CurrentKey()) ?? new MetaProfile();
public void SaveCurrent(MetaProfile profile)
{
Write(CurrentKey(), profile);
WriteLegacyExport(LegacyProfileName(), profile);
}
public bool Select(string? name)
{
string normalized = Normalize(name);
if (!IsKnown(normalized))
return false;
_selected = Canonical(normalized);
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
return true;
}
public bool Create(
string? name,
bool copyCurrent,
MetaProfile current,
out string notice)
{
string normalized = Normalize(name);
if (normalized.Length is < 1 or > 64
|| normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "Enter a unique Meta profile name (1-64 characters).";
return false;
}
MetaProfile document = copyCurrent
? Clone(current)
: new MetaProfile();
Write(NamedKey(normalized), document);
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = normalized;
SelectedByCharacter[CharacterKey()] = normalized;
SaveIndex();
WriteLegacyExport(normalized, document);
notice = copyCurrent
? $"Copied Meta profile to {normalized}."
: $"Created Meta profile {normalized}.";
return true;
}
public bool TryImportLegacy(
string? name,
out MetaProfile profile,
out string notice)
{
string normalized = Normalize(name);
if (!_host.Storage.IsAvailable || normalized.Length == 0)
{
profile = new MetaProfile();
notice = "Legacy Meta storage is unavailable.";
return false;
}
string? key = _host.Storage.List("imports")
.Concat(_host.Storage.List("exports"))
.FirstOrDefault(candidate =>
candidate.EndsWith(".met", StringComparison.OrdinalIgnoreCase)
&& Path.GetFileNameWithoutExtension(candidate).Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
string? source = key is null ? null : _host.Storage.ReadText(key);
if (string.IsNullOrWhiteSpace(source))
{
profile = new MetaProfile();
notice = $"VTank Meta file '{normalized}.met' was not found in imports.";
return false;
}
if (!VtankMetaProfileSerializer.TryLoad(source, out profile, out string error))
{
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
Names.Add(normalized);
_selected = Names.First(existing => existing.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
SelectedByCharacter[CharacterKey()] = _selected;
SaveIndex();
SaveCurrent(profile);
notice = $"Imported VTank Meta profile {_selected}.";
return true;
}
public MetaProfile ClearCurrent()
{
var empty = new MetaProfile();
SaveCurrent(empty);
return empty;
}
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? $"profiles/meta/by-character/{Hash(_character)}.json"
: NamedKey(_selected);
private static string NamedKey(string name) =>
$"profiles/meta/named/{Hash(name)}.json";
private string CharacterKey() =>
string.IsNullOrWhiteSpace(_character) ? "anonymous" : _character;
private bool IsKnown(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
|| Names.Contains(name, StringComparer.OrdinalIgnoreCase);
private string Canonical(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: Names.First(existing => existing.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private void SaveIndex() => Write(IndexKey, _index);
private void WriteLegacyExport(string name, MetaProfile profile)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.met",
VtankMetaProfileSerializer.Save(profile));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTank Meta export could not be saved: {error.Message}");
}
}
private string LegacyProfileName() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_character) ? ByCharacter : _character
: _selected;
private static string LegacyFileName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Meta" : result.ToString();
}
private T? Read<T>(string key)
{
if (!_host.Storage.IsAvailable)
return default;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? default
: JsonSerializer.Deserialize<T>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"meta",
key,
json,
error);
_host.Log.Error(RecoveryNotice, error);
return default;
}
}
private void Write<T>(string key, T value)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(value, Options));
}
catch (Exception error)
{
_host.Log.Error($"Unable to save MossTank Meta profile '{key}'.", error);
}
}
private static MetaProfile Clone(MetaProfile profile) =>
JsonSerializer.Deserialize<MetaProfile>(
JsonSerializer.Serialize(profile, Options),
Options) ?? new MetaProfile();
private static string Normalize(string? name) => name?.Trim() ?? string.Empty;
private static string Hash(string value)
{
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(value.ToLowerInvariant()));
return Convert.ToHexString(hash.AsSpan(0, 12)).ToLowerInvariant();
}
private sealed class IndexDocument
{
public List<string>? Names { get; set; } = [];
public Dictionary<string, string>? SelectedByCharacter { get; set; } = [];
}
}

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,7 @@ public sealed class MossTankPlugin : IAcDreamPlugin
private IPluginHost? _host;
private MossTankPanel? _panel;
private Action<double>? _tick;
private IDisposable? _commandRegistration;
public void Initialize(IPluginHost host)
{
@ -36,10 +37,19 @@ public sealed class MossTankPlugin : IAcDreamPlugin
string directory =
Path.GetDirectoryName(typeof(MossTankPlugin).Assembly.Location) ?? ".";
// Two panels with complementary visible bindings stand in for a tab
// control: only one is ever on screen, and switching is just an Action.
_host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank.xml"), _panel);
_host.Ui.AddMarkupPanel(Path.Combine(directory, "mosstank-settings.xml"), _panel);
_host.Ui.AddPanel(
new PluginPanelDescriptor("main", "MossTank")
{
IconText = "MT",
StartVisible = true,
ShowInSidePanel = true,
},
Path.Combine(directory, "mosstank.xml"),
_panel);
_commandRegistration = _host.Commands.Register(
"vt",
_panel.ExecuteVtankCommand);
_tick = _panel.OnTick;
_host.Events.Tick += _tick;
@ -55,7 +65,10 @@ public sealed class MossTankPlugin : IAcDreamPlugin
{
if (_host is not null && _tick is not null)
_host.Events.Tick -= _tick;
_commandRegistration?.Dispose();
_commandRegistration = null;
_tick = null;
_panel?.Disable();
_host?.Log.Info("MossTank disabled");
}
}

View file

@ -0,0 +1,46 @@
using System.Security.Cryptography;
using System.Text;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Preserves an unreadable profile before a caller falls back to defaults.
/// Recovery is intentionally append-only and manifest-scoped; a corrupt file
/// is never deleted or silently overwritten as part of load.
/// </summary>
internal static class MossTankProfileRecovery
{
internal static string Preserve(
IPluginHost host,
string family,
string key,
string? content,
Exception error)
{
string summary = $"{family} profile '{key}' could not be loaded: "
+ error.Message;
if (!host.Storage.IsAvailable || string.IsNullOrEmpty(content))
return summary;
try
{
byte[] identity = SHA256.HashData(
Encoding.UTF8.GetBytes(key + "\n" + content));
string recoveryKey = $"recovery/{family.ToLowerInvariant()}/"
+ $"{Convert.ToHexString(identity)[..16]}.txt";
string payload = $"Original key: {key}\n"
+ $"Load error: {error.Message}\n\n"
+ content;
host.Storage.WriteText(recoveryKey, payload);
return summary + $" Raw data was preserved as {recoveryKey}.";
}
catch (Exception backupError)
{
host.Log.Warn(
$"MossTank could not preserve corrupt {family} profile: "
+ backupError.Message);
return summary;
}
}
}

View file

@ -0,0 +1,922 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// VTank-compatible macro profile lifecycle. "By char" resolves to a distinct
/// durable document per character; named profiles are explicit shared copies.
/// </summary>
internal sealed class MossTankProfileStore
{
public const string ByCharacter = "By char";
private const string LegacyKey = "profile.json";
private const string IndexKey = "profiles/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly IPluginHost _host;
private ProfileIndex _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
public MossTankProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<ProfileIndex>(IndexKey) ?? new ProfileIndex();
_index.Profiles ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter
?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
public bool MineOnly => _index.MineOnly;
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames
{
get
{
IEnumerable<ProfileEntry> entries = _index.Profiles;
if (MineOnly && !string.IsNullOrWhiteSpace(_characterName))
{
entries = entries.Where(entry => string.Equals(
entry.Owner,
_characterName,
StringComparison.OrdinalIgnoreCase));
}
return new[] { ByCharacter }
.Concat(entries.Select(static entry => entry.Name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(static name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
/// <summary>Returns true when a different character/profile must be loaded.</summary>
public bool BindCharacter(string? characterName)
{
string normalized = string.IsNullOrWhiteSpace(characterName)
? string.Empty
: characterName.Trim();
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
_characterName = normalized;
string selectionKey = CharacterSelectionKey();
_selected = _index.SelectedByCharacter.TryGetValue(
selectionKey,
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
if (!_index.SelectedByCharacter.ContainsKey(selectionKey))
{
_index.SelectedByCharacter[selectionKey] = _selected;
SaveIndex();
}
return true;
}
public void SetMineOnly(bool value)
{
if (_index.MineOnly == value)
return;
_index.MineOnly = value;
if (!AvailableNames.Contains(_selected, StringComparer.OrdinalIgnoreCase))
{
_selected = ByCharacter;
_index.SelectedByCharacter[CharacterSelectionKey()] = _selected;
}
SaveIndex();
}
public bool Select(string? name)
{
string normalized = NormalizeName(name);
if (!IsKnown(normalized))
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[CharacterSelectionKey()] = _selected;
SaveIndex();
return true;
}
public bool Create(
string? name,
bool copyCurrent,
CombatSettings combat,
BuffSettings buffs,
VitalSettings vitals,
InventorySettings inventory,
ISet<string> noBuffItemNames,
out string notice)
{
string normalized = NormalizeName(name);
if (!ValidNamedProfile(normalized, out notice))
return false;
ProfileDocument document = copyCurrent
? ProfileDocument.Capture(
combat, buffs, vitals, inventory, noBuffItemNames)
: ProfileDocument.CreateDefaults();
Write(ProfileKey(normalized, byCharacter: false), document);
int existing = _index.Profiles.FindIndex(entry => string.Equals(
entry.Name,
normalized,
StringComparison.OrdinalIgnoreCase));
var entry = new ProfileEntry { Name = normalized, Owner = _characterName };
if (existing >= 0)
_index.Profiles[existing] = entry;
else
_index.Profiles.Add(entry);
_selected = normalized;
_index.SelectedByCharacter[CharacterSelectionKey()] = normalized;
SaveIndex();
document.Apply(combat, buffs, vitals, inventory, noBuffItemNames);
notice = copyCurrent
? $"Copied current settings to {normalized}."
: $"Created profile {normalized}.";
return true;
}
public void LoadCurrent(
CombatSettings combat,
BuffSettings buffs,
VitalSettings vitals,
InventorySettings inventory,
ISet<string> noBuffItemNames)
{
ProfileDocument? document = Read<ProfileDocument>(CurrentProfileKey());
if (document is null
&& _selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
document = Read<ProfileDocument>(LegacyKey);
}
(document ?? ProfileDocument.CreateDefaults()).Apply(
combat,
buffs,
vitals,
inventory,
noBuffItemNames);
}
public void SaveCurrent(
CombatSettings combat,
BuffSettings buffs,
VitalSettings vitals,
InventorySettings inventory,
ISet<string> noBuffItemNames) => Write(
CurrentProfileKey(),
ProfileDocument.Capture(
combat, buffs, vitals, inventory, noBuffItemNames));
public void ClearCurrent(
CombatSettings combat,
BuffSettings buffs,
VitalSettings vitals,
InventorySettings inventory,
ISet<string> noBuffItemNames)
{
ProfileDocument defaults = ProfileDocument.CreateDefaults();
defaults.Apply(combat, buffs, vitals, inventory, noBuffItemNames);
Write(CurrentProfileKey(), defaults);
}
/// <summary>
/// VTank's <c>opt setinall</c>: update every named profile and every
/// character profile known to the durable index, including the active
/// character even when it has never selected a named profile.
/// </summary>
public int SetOptionInAll(string name, MonsterValue value)
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (ProfileEntry entry in _index.Profiles)
keys.Add(ProfileKey(entry.Name, byCharacter: false));
foreach (string character in _index.SelectedByCharacter.Keys)
{
keys.Add(ProfileKey(
character.Equals("_default", StringComparison.OrdinalIgnoreCase)
? string.Empty
: character,
byCharacter: true));
}
keys.Add(ProfileKey(_characterName, byCharacter: true));
foreach (string key in keys)
{
ProfileDocument document = Read<ProfileDocument>(key)
?? ProfileDocument.CreateDefaults();
document.Combat ??= CombatProfileDocument.Capture(new CombatSettings());
document.Combat.DynamicSettings ??=
new Dictionary<string, DynamicSettingDocument>(
StringComparer.OrdinalIgnoreCase);
document.Combat.DynamicSettings[name] = DynamicSettingDocument.From(value);
Write(key, document);
}
return keys.Count;
}
private static bool ValidNamedProfile(string name, out string notice)
{
if (name.Length is < 1 or > 64)
{
notice = "Enter a profile name (1-64 characters).";
return false;
}
if (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in character profile.";
return false;
}
notice = string.Empty;
return true;
}
private bool IsKnown(string name) =>
name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Profiles.Any(entry => entry.Name.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private string CanonicalName(string name) =>
name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Profiles.First(entry => entry.Name.Equals(
name,
StringComparison.OrdinalIgnoreCase)).Name;
private string CurrentProfileKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
private static string ProfileKey(string value, bool byCharacter)
{
string identity = (byCharacter ? "char:" : "named:")
+ value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/macro/{hash}.json";
}
private string CharacterSelectionKey() => string.IsNullOrWhiteSpace(
_characterName) ? "_default" : _characterName;
private static string NormalizeName(string? name) => name?.Trim() ?? string.Empty;
private T? Read<T>(string key) where T : class
{
if (!_host.Storage.IsAvailable)
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"macro",
key,
json,
error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
}
catch (Exception error)
{
_host.Log.Warn($"MossTank profile could not be saved: {error.Message}");
}
}
private void SaveIndex() => Write(IndexKey, _index);
private sealed class ProfileIndex
{
public int Version { get; set; } = 1;
public bool MineOnly { get; set; } = true;
public List<ProfileEntry> Profiles { get; set; } = [];
public Dictionary<string, string> SelectedByCharacter { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
private sealed class ProfileEntry
{
public string Name { get; set; } = string.Empty;
public string Owner { get; set; } = string.Empty;
}
private sealed class ProfileDocument
{
public int Version { get; set; } = 6;
// Version-2 compatibility fields remain at the top level.
public string[] ItemNames { get; set; } = [];
public string[] ConsumableNames { get; set; } = [];
public Dictionary<string, ConsumableCategory> ConsumableCategories
{ get; set; } = new(StringComparer.Ordinal);
public string[] NoBuffItemNames { get; set; } = [];
public CombatProfileDocument? Combat { get; set; }
public BuffProfileDocument? Buffs { get; set; }
public VitalProfileDocument? Vitals { get; set; }
public InventoryProfileDocument? Inventory { get; set; }
public static ProfileDocument Capture(
CombatSettings combat,
BuffSettings buffs,
VitalSettings vitals,
InventorySettings inventory,
ISet<string> noBuffItemNames) => new()
{
ItemNames = Sorted(combat.CombatItemNames),
ConsumableNames = Sorted(combat.ConsumableNames),
ConsumableCategories = combat.ConsumableCategories.ToDictionary(
static pair => pair.Key,
static pair => pair.Value,
StringComparer.Ordinal),
NoBuffItemNames = Sorted(noBuffItemNames),
Combat = CombatProfileDocument.Capture(combat),
Buffs = BuffProfileDocument.Capture(buffs),
Vitals = VitalProfileDocument.Capture(vitals),
Inventory = InventoryProfileDocument.Capture(inventory),
};
public static ProfileDocument CreateDefaults() => Capture(
new CombatSettings(),
new BuffSettings(),
new VitalSettings(),
new InventorySettings(),
new HashSet<string>(StringComparer.Ordinal));
public void Apply(
CombatSettings combat,
BuffSettings buffs,
VitalSettings vitals,
InventorySettings inventory,
ISet<string> noBuffItemNames)
{
(Combat ?? CombatProfileDocument.Capture(new CombatSettings()))
.Apply(combat);
(Buffs ?? BuffProfileDocument.Capture(new BuffSettings())).Apply(buffs);
(Vitals ?? VitalProfileDocument.Capture(new VitalSettings())).Apply(vitals);
(Inventory ?? InventoryProfileDocument.Capture(new InventorySettings()))
.Apply(inventory);
Replace(combat.CombatItemNames, ItemNames);
combat.CombatItemObjectIds.Clear();
Replace(combat.ConsumableNames, ConsumableNames);
combat.ConsumableCategories.Clear();
foreach ((string name, ConsumableCategory category) in
ConsumableCategories
?? new Dictionary<string, ConsumableCategory>())
{
if (combat.ConsumableNames.Contains(name))
combat.ConsumableCategories[name] = category;
}
Replace(noBuffItemNames, NoBuffItemNames);
}
}
private sealed class InventoryProfileDocument
{
public bool ManaChargesWhenOff { get; set; } = true;
public bool AutoStack { get; set; } = true;
public bool AutoCram { get; set; }
public bool AutoCraftItems { get; set; } = true;
public bool SplitPeas { get; set; } = true;
public int CriticalComponentMinimum { get; set; } = 4;
public int NormalComponentMinimum { get; set; } = 20;
public int IdleComponentMinimum { get; set; } = 20;
public int IdleHealthKitCount { get; set; } = 2;
public int IdleStaminaKitCount { get; set; } = 2;
public int IdleManaKitCount { get; set; } = 2;
public int IdleHealthFoodCount { get; set; } = 15;
public int IdleStaminaFoodCount { get; set; } = 15;
public int IdleManaFoodCount { get; set; } = 15;
public bool RefillWornMana { get; set; } = true;
public int RefillWornManaPercent { get; set; } = 33;
public double ScanIntervalSeconds { get; set; } = 0.25d;
public bool EnableLooting { get; set; }
public string LootClassifierId { get; set; } = string.Empty;
public bool LootPriorityBoost { get; set; }
public bool LootAllCorpses { get; set; }
public bool LootFellowCorpses { get; set; }
public bool LootOnlyRareCorpses { get; set; }
public bool ReadUnknownScrolls { get; set; } = true;
public bool CombineSalvage { get; set; } = true;
public int ManaStoneLootCount { get; set; } = 4;
public int ManaTankMinimumMana { get; set; } = 1000;
public float CorpseApproachRange { get; set; } = 40f;
public double CorpseOpenTimeoutSeconds { get; set; } = 1.5d;
public int BlacklistCorpseOpenAttemptCount { get; set; } = 30;
public double BlacklistCorpseOpenTimeoutSeconds { get; set; } = 200d;
public double CorpseCacheTimeoutMinutes { get; set; } = 60d;
public int CorpseLootItemMaxAttempts { get; set; } = 20;
public double LootScanIntervalSeconds { get; set; } = 0.25d;
public LootRuleDocument[] LootRules { get; set; } = [];
public static InventoryProfileDocument Capture(InventorySettings settings) =>
new()
{
ManaChargesWhenOff = settings.ManaChargesWhenOff,
AutoStack = settings.AutoStack,
AutoCram = settings.AutoCram,
AutoCraftItems = settings.AutoCraftItems,
SplitPeas = settings.SplitPeas,
CriticalComponentMinimum = settings.CriticalComponentMinimum,
NormalComponentMinimum = settings.NormalComponentMinimum,
IdleComponentMinimum = settings.IdleComponentMinimum,
IdleHealthKitCount = settings.IdleHealthKitCount,
IdleStaminaKitCount = settings.IdleStaminaKitCount,
IdleManaKitCount = settings.IdleManaKitCount,
IdleHealthFoodCount = settings.IdleHealthFoodCount,
IdleStaminaFoodCount = settings.IdleStaminaFoodCount,
IdleManaFoodCount = settings.IdleManaFoodCount,
RefillWornMana = settings.RefillWornMana,
RefillWornManaPercent = settings.RefillWornManaPercent,
ScanIntervalSeconds = settings.ScanIntervalSeconds,
EnableLooting = settings.Loot.Enabled,
LootClassifierId = settings.Loot.ExternalClassifierId,
LootPriorityBoost = settings.Loot.PriorityBoost,
LootAllCorpses = settings.Loot.LootAllCorpses,
LootFellowCorpses = settings.Loot.LootFellowCorpses,
LootOnlyRareCorpses = settings.Loot.LootOnlyRareCorpses,
ReadUnknownScrolls = settings.Loot.ReadUnknownScrolls,
CombineSalvage = settings.Loot.CombineSalvage,
ManaStoneLootCount = settings.Loot.ManaStoneLootCount,
ManaTankMinimumMana = settings.Loot.ManaTankMinimumMana,
CorpseApproachRange = settings.Loot.CorpseApproachRange,
CorpseOpenTimeoutSeconds =
settings.Loot.CorpseOpenTimeoutSeconds,
BlacklistCorpseOpenAttemptCount =
settings.Loot.BlacklistCorpseOpenAttemptCount,
BlacklistCorpseOpenTimeoutSeconds =
settings.Loot.BlacklistCorpseOpenTimeoutSeconds,
CorpseCacheTimeoutMinutes =
settings.Loot.CorpseCacheTimeoutMinutes,
CorpseLootItemMaxAttempts =
settings.Loot.CorpseLootItemMaxAttempts,
LootScanIntervalSeconds = settings.Loot.ScanIntervalSeconds,
LootRules = settings.Loot.Rules
.Select(LootRuleDocument.From)
.ToArray(),
};
public void Apply(InventorySettings settings)
{
settings.ManaChargesWhenOff = ManaChargesWhenOff;
settings.AutoStack = AutoStack;
settings.AutoCram = AutoCram;
settings.AutoCraftItems = AutoCraftItems;
settings.SplitPeas = SplitPeas;
settings.CriticalComponentMinimum = Math.Clamp(
CriticalComponentMinimum, 0, 1000);
settings.NormalComponentMinimum = Math.Clamp(
NormalComponentMinimum, 0, 1000);
settings.IdleComponentMinimum = Math.Clamp(
IdleComponentMinimum, 0, 1000);
settings.IdleHealthKitCount = Math.Clamp(IdleHealthKitCount, 0, 1000);
settings.IdleStaminaKitCount = Math.Clamp(IdleStaminaKitCount, 0, 1000);
settings.IdleManaKitCount = Math.Clamp(IdleManaKitCount, 0, 1000);
settings.IdleHealthFoodCount = Math.Clamp(IdleHealthFoodCount, 0, 1000);
settings.IdleStaminaFoodCount = Math.Clamp(IdleStaminaFoodCount, 0, 1000);
settings.IdleManaFoodCount = Math.Clamp(IdleManaFoodCount, 0, 1000);
settings.RefillWornMana = RefillWornMana;
settings.RefillWornManaPercent = Math.Clamp(
RefillWornManaPercent,
0,
99);
settings.ScanIntervalSeconds = Math.Clamp(
ScanIntervalSeconds,
0.05d,
10d);
settings.Loot.Enabled = EnableLooting;
settings.Loot.ExternalClassifierId = LootClassifierId?.Trim()
?? string.Empty;
settings.Loot.PriorityBoost = LootPriorityBoost;
settings.Loot.LootAllCorpses = LootAllCorpses;
settings.Loot.LootFellowCorpses = LootFellowCorpses;
settings.Loot.LootOnlyRareCorpses = LootOnlyRareCorpses;
settings.Loot.ReadUnknownScrolls = ReadUnknownScrolls;
settings.Loot.CombineSalvage = CombineSalvage;
settings.Loot.ManaStoneLootCount = Math.Clamp(
ManaStoneLootCount,
0,
100);
settings.Loot.ManaTankMinimumMana = Math.Clamp(
ManaTankMinimumMana,
1,
int.MaxValue);
settings.Loot.CorpseApproachRange = Math.Clamp(
CorpseApproachRange,
2f,
100f);
settings.Loot.CorpseOpenTimeoutSeconds = Math.Clamp(
CorpseOpenTimeoutSeconds,
0.25d,
30d);
settings.Loot.BlacklistCorpseOpenAttemptCount = Math.Clamp(
BlacklistCorpseOpenAttemptCount,
1,
1000);
settings.Loot.BlacklistCorpseOpenTimeoutSeconds = Math.Clamp(
BlacklistCorpseOpenTimeoutSeconds,
1d,
3600d);
settings.Loot.CorpseCacheTimeoutMinutes = Math.Clamp(
CorpseCacheTimeoutMinutes,
1d,
1440d);
settings.Loot.CorpseLootItemMaxAttempts = Math.Clamp(
CorpseLootItemMaxAttempts,
1,
100);
settings.Loot.ScanIntervalSeconds = Math.Clamp(
LootScanIntervalSeconds,
0.05d,
5d);
settings.Loot.Rules.Clear();
foreach (LootRuleDocument rule in LootRules ?? [])
settings.Loot.Rules.Add(rule.ToRule());
}
}
private sealed class LootRuleDocument
{
public string Name { get; set; } = "Rule";
public string Expression { get; set; } = "*";
public LootAction Action { get; set; } = LootAction.Keep;
public int KeepCount { get; set; } = 1;
public int Priority { get; set; }
public static LootRuleDocument From(LootRule rule) => new()
{
Name = rule.Name,
Expression = rule.Expression,
Action = rule.Action,
KeepCount = rule.KeepCount,
Priority = rule.Priority,
};
public LootRule ToRule() => new()
{
Name = string.IsNullOrWhiteSpace(Name) ? "Rule" : Name.Trim(),
Expression = string.IsNullOrWhiteSpace(Expression)
? "*"
: Expression.Trim(),
Action = Action,
KeepCount = Math.Clamp(KeepCount, 0, 100000),
Priority = Math.Clamp(Priority, -1000, 1000),
};
}
private sealed class CombatProfileDocument
{
public bool Enabled { get; set; } = true;
public float MaximumRange { get; set; } = 5f;
public float ApproachDistance { get; set; }
public bool IdlePeaceMode { get; set; }
public TargetSelectionMethod SelectionMethod { get; set; } =
TargetSelectionMethod.Both;
public float TargetSelectAngleRange { get; set; } = 5f;
public bool TargetLock { get; set; }
public PluginAttackHeight AttackHeight { get; set; } =
PluginAttackHeight.Medium;
public float AttackPower { get; set; } = 0.5f;
public bool AutoAttackPower { get; set; } = true;
public bool UseRecklessness { get; set; } = true;
public double ScanIntervalSeconds { get; set; } = 0.25;
public DebuffEachFirst DebuffEachFirst { get; set; } = DebuffEachFirst.One;
public DebuffSelectionMethod DebuffSelectionMethod { get; set; } =
DebuffSelectionMethod.Skill;
public double DebuffPrecastSeconds { get; set; } = 5d;
public bool SwitchWandsToDebuff { get; set; }
public bool UseArcs { get; set; } = true;
public float ArcRange { get; set; } = 5f;
public float RingDistance { get; set; } = 5f;
public int MinimumRingTargets { get; set; } = 4;
public bool DeleteGhostMonsters { get; set; } = true;
public int GhostMonsterSpellAttemptCount { get; set; } = 200;
public int BlacklistMonsterAttemptCount { get; set; } = 4;
public double BlacklistMonsterTimeoutSeconds { get; set; } = 120d;
public bool DeleteGhostMonstersByHealthTracker { get; set; } = true;
public double GhostDeleteHealthTrackerSeconds { get; set; } = 30d;
public bool SummonPets { get; set; } = true;
public PetRangeMode PetRangeMode { get; set; } = PetRangeMode.AttackDistance;
public float PetCustomRange { get; set; } = 5f;
public int PetMonsterDensity { get; set; } = 1;
public int PetRefillCountIdle { get; set; } = 3;
public int PetRefillCountNormal { get; set; } = 1;
public string MetaState { get; set; } = "Default";
public Dictionary<string, DynamicSettingDocument> DynamicSettings { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
public MonsterRuleDocument[] Rules { get; set; } =
[MonsterRuleDocument.From(new MonsterRule("DEFAULT", 0))];
public static CombatProfileDocument Capture(CombatSettings value) => new()
{
Enabled = value.Enabled,
MaximumRange = value.MaximumRange,
ApproachDistance = value.ApproachDistance,
IdlePeaceMode = value.IdlePeaceMode,
SelectionMethod = value.SelectionMethod,
TargetSelectAngleRange = value.TargetSelectAngleRange,
TargetLock = value.TargetLock,
AttackHeight = value.AttackHeight,
AttackPower = value.AttackPower,
AutoAttackPower = value.AutoAttackPower,
UseRecklessness = value.UseRecklessness,
ScanIntervalSeconds = value.ScanIntervalSeconds,
DebuffEachFirst = value.DebuffEachFirst,
DebuffSelectionMethod = value.DebuffSelectionMethod,
DebuffPrecastSeconds = value.DebuffPrecastSeconds,
SwitchWandsToDebuff = value.SwitchWandsToDebuff,
UseArcs = value.UseArcs,
ArcRange = value.ArcRange,
RingDistance = value.RingDistance,
MinimumRingTargets = value.MinimumRingTargets,
DeleteGhostMonsters = value.DeleteGhostMonsters,
GhostMonsterSpellAttemptCount = value.GhostMonsterSpellAttemptCount,
BlacklistMonsterAttemptCount = value.BlacklistMonsterAttemptCount,
BlacklistMonsterTimeoutSeconds = value.BlacklistMonsterTimeoutSeconds,
DeleteGhostMonstersByHealthTracker = value.DeleteGhostMonstersByHealthTracker,
GhostDeleteHealthTrackerSeconds = value.GhostDeleteHealthTrackerSeconds,
SummonPets = value.SummonPets,
PetRangeMode = value.PetRangeMode,
PetCustomRange = value.PetCustomRange,
PetMonsterDensity = value.PetMonsterDensity,
PetRefillCountIdle = value.PetRefillCountIdle,
PetRefillCountNormal = value.PetRefillCountNormal,
MetaState = value.MetaState,
DynamicSettings = value.DynamicSettings.ToDictionary(
static pair => pair.Key,
static pair => DynamicSettingDocument.From(pair.Value),
StringComparer.OrdinalIgnoreCase),
Rules = value.Rules.Select(MonsterRuleDocument.From).ToArray(),
};
public void Apply(CombatSettings value)
{
value.Enabled = Enabled;
value.MaximumRange = Math.Clamp(MaximumRange, 2f, 100f);
value.ApproachDistance = Math.Clamp(ApproachDistance, 0f, 100f);
value.IdlePeaceMode = IdlePeaceMode;
value.SelectionMethod = SelectionMethod;
value.TargetSelectAngleRange = Math.Clamp(
TargetSelectAngleRange, 2f, value.MaximumRange);
value.TargetLock = TargetLock;
value.AttackHeight = AttackHeight;
value.AttackPower = Math.Clamp(AttackPower, 0f, 1f);
value.AutoAttackPower = AutoAttackPower;
value.UseRecklessness = UseRecklessness;
value.ScanIntervalSeconds = Math.Clamp(ScanIntervalSeconds, 0.05, 5d);
value.DebuffEachFirst = DebuffEachFirst;
value.DebuffSelectionMethod = DebuffSelectionMethod;
value.DebuffPrecastSeconds = Math.Clamp(DebuffPrecastSeconds, 0d, 60d);
value.SwitchWandsToDebuff = SwitchWandsToDebuff;
value.UseArcs = UseArcs;
value.ArcRange = Math.Clamp(ArcRange, 1f, 100f);
value.RingDistance = Math.Clamp(RingDistance, 1f, 100f);
value.MinimumRingTargets = Math.Clamp(MinimumRingTargets, 1, 25);
value.DeleteGhostMonsters = DeleteGhostMonsters;
value.GhostMonsterSpellAttemptCount = Math.Clamp(
GhostMonsterSpellAttemptCount, 1, 1000);
value.BlacklistMonsterAttemptCount = Math.Clamp(
BlacklistMonsterAttemptCount, 1, 20);
value.BlacklistMonsterTimeoutSeconds = Math.Clamp(
BlacklistMonsterTimeoutSeconds, 1d, 3600d);
value.DeleteGhostMonstersByHealthTracker = DeleteGhostMonstersByHealthTracker;
value.GhostDeleteHealthTrackerSeconds = Math.Clamp(
GhostDeleteHealthTrackerSeconds, 1d, 300d);
value.SummonPets = SummonPets;
value.PetRangeMode = PetRangeMode;
value.PetCustomRange = Math.Clamp(PetCustomRange, 1f, 100f);
value.PetMonsterDensity = Math.Clamp(PetMonsterDensity, 1, 25);
value.PetRefillCountIdle = Math.Clamp(PetRefillCountIdle, 0, 3);
value.PetRefillCountNormal = Math.Clamp(PetRefillCountNormal, 0, 3);
value.MetaState = string.IsNullOrWhiteSpace(MetaState)
? "Default"
: MetaState;
value.DynamicSettings.Clear();
foreach ((string name, DynamicSettingDocument setting) in
DynamicSettings ?? new Dictionary<string, DynamicSettingDocument>())
{
value.DynamicSettings[name] = setting.ToValue();
}
value.Rules.Clear();
foreach (MonsterRuleDocument rule in Rules ?? [])
{
try { value.Rules.Add(rule.ToRule()); }
catch (FormatException) { }
}
if (!value.Rules.Any(static rule => rule.IsDefault))
value.Rules.Add(new MonsterRule("DEFAULT", 0));
}
}
private sealed class DynamicSettingDocument
{
public MonsterValueKind Kind { get; set; }
public double Number { get; set; }
public string Text { get; set; } = string.Empty;
public bool Boolean { get; set; }
public static DynamicSettingDocument From(MonsterValue value) => new()
{
Kind = value.Kind,
Number = value.Number,
Text = value.Text,
Boolean = value.Boolean,
};
public MonsterValue ToValue() => Kind switch
{
MonsterValueKind.Number => MonsterValue.FromNumber(Number),
MonsterValueKind.Boolean => MonsterValue.FromBoolean(Boolean),
_ => MonsterValue.FromText(Text ?? string.Empty),
};
}
private sealed class MonsterRuleDocument
{
public string Expression { get; set; } = "DEFAULT";
public MonsterActionFlags Flags { get; set; } = MonsterActionFlags.Attack;
public int Priority { get; set; }
public MonsterDamageType DamageType { get; set; } = MonsterDamageType.Auto;
public MonsterDamageType ExtraVulnerability { get; set; } =
MonsterDamageType.Auto;
public uint WeaponObjectId { get; set; }
public uint OffhandObjectId { get; set; }
public string WeaponName { get; set; } = string.Empty;
public string OffhandName { get; set; } = string.Empty;
public MonsterDamageType PetDamageType { get; set; } =
MonsterDamageType.PlayerAuto;
public static MonsterRuleDocument From(MonsterRule rule) => new()
{
Expression = rule.Expression,
Flags = rule.Actions.Flags,
Priority = rule.Actions.Priority,
DamageType = rule.Actions.DamageType,
ExtraVulnerability = rule.Actions.ExtraVulnerability,
WeaponObjectId = rule.Actions.WeaponObjectId,
OffhandObjectId = rule.Actions.OffhandObjectId,
WeaponName = rule.Actions.WeaponName,
OffhandName = rule.Actions.OffhandName,
PetDamageType = rule.Actions.PetDamageType,
};
public MonsterRule ToRule() => new(Expression, new MonsterRuleActions
{
Flags = Flags,
Priority = Math.Clamp(Priority, -1, 4),
DamageType = DamageType,
ExtraVulnerability = ExtraVulnerability,
WeaponObjectId = WeaponObjectId,
OffhandObjectId = OffhandObjectId,
WeaponName = WeaponName ?? string.Empty,
OffhandName = OffhandName ?? string.Empty,
PetDamageType = PetDamageType,
});
}
private sealed class BuffProfileDocument
{
public bool Enabled { get; set; } = true;
public bool IdleBuffTopoff { get; set; }
public double IdleBuffTopoffSeconds { get; set; } = 1200d;
public double RebuffWhenUnderSeconds { get; set; } = 300d;
public int SkillExcessOverDifficulty { get; set; } = 5;
public bool BuffAttributes { get; set; } = true;
public bool BuffProtections { get; set; } = true;
public bool BuffAuras { get; set; } = true;
public bool BuffBanes { get; set; } = true;
public bool BuffRegeneration { get; set; } = true;
public bool BuffOther { get; set; }
public bool BuffTrainedSkillsOnly { get; set; } = true;
public static BuffProfileDocument Capture(BuffSettings value) => new()
{
Enabled = value.Enabled,
IdleBuffTopoff = value.IdleBuffTopoff,
IdleBuffTopoffSeconds = value.IdleBuffTopoffSeconds,
RebuffWhenUnderSeconds = value.RebuffWhenUnderSeconds,
SkillExcessOverDifficulty = value.SkillExcessOverDifficulty,
BuffAttributes = value.BuffAttributes,
BuffProtections = value.BuffProtections,
BuffAuras = value.BuffAuras,
BuffBanes = value.BuffBanes,
BuffRegeneration = value.BuffRegeneration,
BuffOther = value.BuffOther,
BuffTrainedSkillsOnly = value.BuffTrainedSkillsOnly,
};
public void Apply(BuffSettings value)
{
value.Enabled = Enabled;
value.IdleBuffTopoff = IdleBuffTopoff;
value.IdleBuffTopoffSeconds = Math.Clamp(
IdleBuffTopoffSeconds, 30d, 7200d);
value.RebuffWhenUnderSeconds = Math.Clamp(
RebuffWhenUnderSeconds, 30d, 1800d);
value.SkillExcessOverDifficulty = Math.Clamp(
SkillExcessOverDifficulty, -100, 100);
value.BuffAttributes = BuffAttributes;
value.BuffProtections = BuffProtections;
value.BuffAuras = BuffAuras;
value.BuffBanes = BuffBanes;
value.BuffRegeneration = BuffRegeneration;
value.BuffOther = BuffOther;
value.BuffTrainedSkillsOnly = BuffTrainedSkillsOnly;
}
}
private sealed class VitalProfileDocument
{
public bool Enabled { get; set; } = true;
public double NormalHealth { get; set; } = 0.75;
public double NormalStamina { get; set; } = 0.50;
public double NormalMana { get; set; } = 0.50;
public double NoTargetHealth { get; set; } = 0.01;
public double NoTargetStamina { get; set; } = 0.01;
public double NoTargetMana { get; set; } = 0.01;
public double HelperHealth { get; set; } = 0.20;
public double HelperStamina { get; set; } = 0.01;
public double HelperMana { get; set; } = 0.01;
public bool HelpOthers { get; set; } = true;
public static VitalProfileDocument Capture(VitalSettings value) => new()
{
Enabled = value.Enabled,
NormalHealth = value.NormalHealth,
NormalStamina = value.NormalStamina,
NormalMana = value.NormalMana,
NoTargetHealth = value.NoTargetHealth,
NoTargetStamina = value.NoTargetStamina,
NoTargetMana = value.NoTargetMana,
HelperHealth = value.HelperHealth,
HelperStamina = value.HelperStamina,
HelperMana = value.HelperMana,
HelpOthers = value.HelpOthers,
};
public void Apply(VitalSettings value)
{
value.Enabled = Enabled;
value.NormalHealth = Clamp(NormalHealth);
value.NormalStamina = Clamp(NormalStamina);
value.NormalMana = Clamp(NormalMana);
value.NoTargetHealth = Clamp(NoTargetHealth);
value.NoTargetStamina = Clamp(NoTargetStamina);
value.NoTargetMana = Clamp(NoTargetMana);
value.HelperHealth = Clamp(HelperHealth);
value.HelperStamina = Clamp(HelperStamina);
value.HelperMana = Clamp(HelperMana);
value.HelpOthers = HelpOthers;
}
private static double Clamp(double value) => Math.Clamp(value, 0d, 1d);
}
private static string[] Sorted(IEnumerable<string> values) => values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.Ordinal)
.OrderBy(static value => value, StringComparer.Ordinal)
.ToArray();
private static void Replace(ISet<string> target, IEnumerable<string>? values)
{
target.Clear();
if (values is null)
return;
foreach (string value in values)
{
if (!string.IsNullOrWhiteSpace(value))
target.Add(value);
}
}
}

View file

@ -0,0 +1,437 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Independent VTank navigation-profile lifecycle. The selected route is
/// remembered per character; "By char" is a private route document and named
/// profiles are reusable copies.
/// </summary>
internal sealed class MossTankRouteProfileStore
{
public const string ByCharacter = "By char";
private const string IndexKey = "profiles/route/index.json";
private static readonly JsonSerializerOptions Options = new()
{
WriteIndented = true,
PropertyNameCaseInsensitive = true,
};
private readonly IPluginHost _host;
private IndexDocument _index;
private string _characterName = string.Empty;
private string _selected = ByCharacter;
public MossTankRouteProfileStore(IPluginHost host)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_index = Read<IndexDocument>(IndexKey) ?? new IndexDocument();
_index.Names ??= [];
_index.SelectedByCharacter = new Dictionary<string, string>(
_index.SelectedByCharacter ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
}
public string Selected => _selected;
public string? RecoveryNotice { get; private set; }
public IReadOnlyList<string> AvailableNames => new[] { ByCharacter }
.Concat(_index.Names)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ThenBy(static name => name, StringComparer.OrdinalIgnoreCase)
.ToArray();
public bool BindCharacter(string? characterName)
{
string normalized = string.IsNullOrWhiteSpace(characterName)
? string.Empty
: characterName.Trim();
if (string.Equals(normalized, _characterName, StringComparison.OrdinalIgnoreCase))
return false;
_characterName = normalized;
_selected = _index.SelectedByCharacter.TryGetValue(
SelectionKey(),
out string? selected)
&& IsKnown(selected)
? CanonicalName(selected)
: ByCharacter;
return true;
}
public bool Select(string? name)
{
string normalized = name?.Trim() ?? string.Empty;
if (!IsKnown(normalized))
return false;
_selected = CanonicalName(normalized);
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
return true;
}
public bool Create(
string? name,
bool copyCurrent,
NavigationSettings current,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
if (normalized.Length is < 1 or > 64)
{
notice = "Enter a route profile name (1-64 characters).";
return false;
}
if (normalized.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase))
{
notice = "'By char' is the built-in route profile.";
return false;
}
Write(
ProfileKey(normalized, byCharacter: false),
copyCurrent
? RouteDocument.Capture(current)
: new RouteDocument());
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
WriteLegacyExport(_selected, copyCurrent ? current : new NavigationSettings());
notice = copyCurrent
? $"Copied route to {_selected}."
: $"Created route profile {_selected}.";
return true;
}
public bool LoadCurrent(NavigationSettings target)
{
ArgumentNullException.ThrowIfNull(target);
RouteDocument? document = Read<RouteDocument>(CurrentKey());
if (document is null)
return false;
document.Apply(target);
return true;
}
public void SaveCurrent(NavigationSettings settings)
{
Write(CurrentKey(), RouteDocument.Capture(settings));
WriteLegacyExport(
_selected.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
? string.IsNullOrWhiteSpace(_characterName)
? ByCharacter
: _characterName
: _selected,
settings);
}
public bool TryImportLegacy(
string? name,
NavigationSettings target,
ISpellCatalog spells,
out string notice)
{
string normalized = name?.Trim() ?? string.Empty;
if (!_host.Storage.IsAvailable || normalized.Length == 0)
{
notice = "Legacy navigation storage is unavailable.";
return false;
}
string? key = _host.Storage.List("imports")
.Concat(_host.Storage.List("exports"))
.FirstOrDefault(candidate =>
candidate.EndsWith(".nav", StringComparison.OrdinalIgnoreCase)
&& Path.GetFileNameWithoutExtension(candidate).Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
string? source = key is null ? null : _host.Storage.ReadText(key);
if (string.IsNullOrWhiteSpace(source))
{
notice = $"VTank navigation file '{normalized}.nav' was not found in imports.";
return false;
}
if (!VtankNavRouteSerializer.TryLoad(source, target, spells, out string error))
{
notice = $"Could not import {Path.GetFileName(key)}: {error}";
return false;
}
if (!_index.Names.Contains(normalized, StringComparer.OrdinalIgnoreCase))
_index.Names.Add(normalized);
_selected = _index.Names.First(entry => entry.Equals(
normalized,
StringComparison.OrdinalIgnoreCase));
_index.SelectedByCharacter[SelectionKey()] = _selected;
SaveIndex();
SaveCurrent(target);
notice = $"Imported VTank navigation profile {_selected}.";
return true;
}
public void ClearCurrent(NavigationSettings target)
{
target.Enabled = false;
target.Priority = false;
target.Mode = RouteMode.Circular;
target.MinimumDistanceMeters = 2d;
target.FollowTargetObjectId = 0u;
target.FollowTargetName = string.Empty;
target.FollowAroundCorners = true;
target.OpenDoors = false;
target.Waypoints.Clear();
SaveCurrent(target);
}
private bool IsKnown(string? name) => name is not null
&& (name.Equals(ByCharacter, StringComparison.OrdinalIgnoreCase)
|| _index.Names.Contains(name, StringComparer.OrdinalIgnoreCase));
private string CanonicalName(string name) => name.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ByCharacter
: _index.Names.First(entry => entry.Equals(
name,
StringComparison.OrdinalIgnoreCase));
private string CurrentKey() => _selected.Equals(
ByCharacter,
StringComparison.OrdinalIgnoreCase)
? ProfileKey(_characterName, byCharacter: true)
: ProfileKey(_selected, byCharacter: false);
private static string ProfileKey(string value, bool byCharacter)
{
string identity = (byCharacter ? "char:" : "named:")
+ value.Trim().ToUpperInvariant();
string hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(identity)));
return $"profiles/route/{hash}.json";
}
private string SelectionKey() => string.IsNullOrWhiteSpace(_characterName)
? "_default"
: _characterName;
private T? Read<T>(string key) where T : class
{
if (!_host.Storage.IsAvailable)
return null;
string? json = null;
try
{
json = _host.Storage.ReadText(key);
return string.IsNullOrWhiteSpace(json)
? null
: JsonSerializer.Deserialize<T>(json, Options);
}
catch (Exception error)
{
RecoveryNotice = MossTankProfileRecovery.Preserve(
_host,
"route",
key,
json,
error);
_host.Log.Warn(RecoveryNotice);
return null;
}
}
private void Write<T>(string key, T document)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(key, JsonSerializer.Serialize(document, Options));
}
catch (Exception error)
{
_host.Log.Warn($"MossTank route profile could not be saved: {error.Message}");
}
}
private void SaveIndex() => Write(IndexKey, _index);
private void WriteLegacyExport(string name, NavigationSettings settings)
{
if (!_host.Storage.IsAvailable)
return;
try
{
_host.Storage.WriteText(
$"exports/{LegacyFileName(name)}.nav",
VtankNavRouteSerializer.Save(settings));
}
catch (Exception error)
{
_host.Log.Warn(
$"MossTank VTank navigation export could not be saved: {error.Message}");
}
}
private static string LegacyFileName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
var result = new StringBuilder(name.Length);
foreach (char value in name.Trim())
{
result.Append(value is '/' or '\\' || invalid.Contains(value)
? '_'
: value);
}
return result.Length == 0 ? "Route" : result.ToString();
}
private sealed class IndexDocument
{
public int Version { get; set; } = 1;
public List<string> Names { get; set; } = [];
public Dictionary<string, string> SelectedByCharacter { get; set; } =
new(StringComparer.OrdinalIgnoreCase);
}
private sealed class RouteDocument
{
public int Version { get; set; } = 1;
public bool Enabled { get; set; }
public bool Priority { get; set; }
public RouteMode Mode { get; set; } = RouteMode.Circular;
public double MinimumDistanceMeters { get; set; } = 2d;
public uint FollowTargetObjectId { get; set; }
public string FollowTargetName { get; set; } = string.Empty;
public bool FollowAroundCorners { get; set; } = true;
public bool OpenDoors { get; set; }
public double DoorIdentifyRangeMeters { get; set; } = 20d;
public double DoorOpenRangeMeters { get; set; } = 4d;
public int DoorLockpickExcessThreshold { get; set; } = -50;
public WaypointDocument[] Waypoints { get; set; } = [];
public static RouteDocument Capture(NavigationSettings value) => new()
{
Enabled = value.Enabled,
Priority = value.Priority,
Mode = value.Mode,
MinimumDistanceMeters = value.MinimumDistanceMeters,
FollowTargetObjectId = value.FollowTargetObjectId,
FollowTargetName = value.FollowTargetName,
FollowAroundCorners = value.FollowAroundCorners,
OpenDoors = value.OpenDoors,
DoorIdentifyRangeMeters = value.DoorIdentifyRangeMeters,
DoorOpenRangeMeters = value.DoorOpenRangeMeters,
DoorLockpickExcessThreshold = value.DoorLockpickExcessThreshold,
Waypoints = value.Waypoints.Select(WaypointDocument.From).ToArray(),
};
public void Apply(NavigationSettings value)
{
value.Enabled = Enabled;
value.Priority = Priority;
value.Mode = Enum.IsDefined(Mode) ? Mode : RouteMode.Circular;
value.MinimumDistanceMeters = Math.Clamp(
MinimumDistanceMeters,
0.5d,
50d);
value.FollowTargetObjectId = FollowTargetObjectId;
value.FollowTargetName = FollowTargetName ?? string.Empty;
value.FollowAroundCorners = FollowAroundCorners;
value.OpenDoors = OpenDoors;
value.DoorIdentifyRangeMeters = Math.Clamp(
DoorIdentifyRangeMeters, 1d, 100d);
value.DoorOpenRangeMeters = Math.Clamp(
DoorOpenRangeMeters, 0.5d, value.DoorIdentifyRangeMeters);
value.DoorLockpickExcessThreshold = Math.Clamp(
DoorLockpickExcessThreshold, -500, 500);
value.Waypoints.Clear();
foreach (WaypointDocument waypoint in Waypoints ?? [])
value.Waypoints.Add(waypoint.ToWaypoint());
}
}
private sealed class WaypointDocument
{
public RouteWaypointType Type { get; set; }
public uint CellId { get; set; }
public double EastWest { get; set; }
public double NorthSouth { get; set; }
public double Elevation { get; set; }
public float HeadingDegrees { get; set; }
public bool IsOutdoor { get; set; }
public uint ObjectId { get; set; }
public string ObjectName { get; set; } = string.Empty;
public int LegacyObjectClass { get; set; }
public bool LegacyReferenceValid { get; set; } = true;
public string Text { get; set; } = string.Empty;
public int DurationMilliseconds { get; set; } = 5000;
public RouteRecallKind Recall { get; set; }
public uint RecallSpellId { get; set; }
public string RecallSpellName { get; set; } = string.Empty;
public float JumpHeadingDegrees { get; set; }
public bool JumpRun { get; set; }
public int JumpChargeMilliseconds { get; set; } = 1000;
public RouteJumpDirection JumpDirection { get; set; }
public static WaypointDocument From(RouteWaypoint value) => new()
{
Type = value.Type,
CellId = value.Position.CellId,
EastWest = value.Position.EastWest,
NorthSouth = value.Position.NorthSouth,
Elevation = value.Position.Elevation,
HeadingDegrees = value.Position.HeadingDegrees,
IsOutdoor = value.Position.IsOutdoor,
ObjectId = value.ObjectId,
ObjectName = value.ObjectName,
LegacyObjectClass = value.LegacyObjectClass,
LegacyReferenceValid = value.LegacyReferenceValid,
Text = value.Text,
DurationMilliseconds = value.DurationMilliseconds,
Recall = value.Recall,
RecallSpellId = value.RecallSpellId,
RecallSpellName = value.RecallSpellName,
JumpHeadingDegrees = value.JumpHeadingDegrees,
JumpRun = value.JumpRun,
JumpChargeMilliseconds = value.JumpChargeMilliseconds,
JumpDirection = value.JumpDirection,
};
public RouteWaypoint ToWaypoint() => new()
{
Type = Enum.IsDefined(Type) ? Type : RouteWaypointType.Point,
Position = new PluginNavigationPosition(
CellId,
EastWest,
NorthSouth,
Elevation,
HeadingDegrees,
IsOutdoor),
ObjectId = ObjectId,
ObjectName = ObjectName ?? string.Empty,
LegacyObjectClass = LegacyObjectClass,
LegacyReferenceValid = LegacyReferenceValid,
Text = Text ?? string.Empty,
DurationMilliseconds = Math.Clamp(DurationMilliseconds, 0, 3_600_000),
Recall = Enum.IsDefined(Recall) ? Recall : RouteRecallKind.Lifestone,
RecallSpellId = RecallSpellId,
RecallSpellName = RecallSpellName ?? string.Empty,
JumpHeadingDegrees = float.IsFinite(JumpHeadingDegrees)
? JumpHeadingDegrees
: 0f,
JumpRun = JumpRun,
JumpChargeMilliseconds = Math.Clamp(
JumpChargeMilliseconds,
0,
10_000),
JumpDirection = Enum.IsDefined(JumpDirection)
? JumpDirection
: RouteJumpDirection.Forward,
};
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,315 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal enum PetAutomationActionKind
{
None,
Refill,
Summon,
}
internal readonly record struct PetAutomationChoice(
PetAutomationActionKind Kind,
PluginInventoryItem Device,
PluginInventoryItem Tool,
PluginCombatTarget Target,
MonsterDamageType DamageType)
{
public static PetAutomationChoice None => default;
}
/// <summary>
/// VTank combat-pet policy. The host still owns inventory, item use and the
/// spawned pet; this type only chooses a device and waits for the exact
/// server UseDone receipt.
/// </summary>
internal sealed class PetAutomation
{
private const double RetailPetCooldownSeconds = 45d;
private const double RefusalRetrySeconds = 1d;
private long _observedCompletionRevision;
private uint _pendingSourceId;
private PetAutomationActionKind _pendingKind;
private double _nextSummonAt;
private double _nextRefillAt;
public bool Tick(
IItemAutomation automation,
ICharacterInfo character,
IReadOnlyList<PluginCombatTarget> targets,
CombatSettings settings,
double now,
out string status)
{
ArgumentNullException.ThrowIfNull(automation);
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(targets);
ArgumentNullException.ThrowIfNull(settings);
ObserveCompletion(automation.LastCompletion, now, out string? completion);
if (completion is not null)
status = completion;
else
status = string.Empty;
if (_pendingSourceId != 0u)
{
status = _pendingKind == PetAutomationActionKind.Refill
? "Refilling combat pet"
: "Summoning combat pet";
return true;
}
if (!settings.SummonPets || !automation.IsAvailable)
return false;
if (automation.IsBusy)
{
status = "Waiting to use combat pet";
return true;
}
IReadOnlyList<PluginInventoryItem> items = automation.CaptureOwnedItems();
PetAutomationChoice choice = Select(
items,
targets,
character,
settings,
automation.ActiveOwnedPetCount,
now >= _nextRefillAt,
now >= _nextSummonAt);
if (choice.Kind == PetAutomationActionKind.None)
return false;
PluginItemCommandResult result = choice.Kind == PetAutomationActionKind.Refill
? automation.Apply(choice.Tool.ObjectId, choice.Device.ObjectId)
: automation.Use(choice.Device.ObjectId);
if (result.Status == PluginItemCommandStatus.Started)
{
_pendingSourceId = choice.Kind == PetAutomationActionKind.Refill
? choice.Tool.ObjectId
: choice.Device.ObjectId;
_pendingKind = choice.Kind;
status = choice.Kind == PetAutomationActionKind.Refill
? $"Refilling {choice.Device.Name}"
: $"Summoning {choice.Device.Name} for {choice.Target.Name}";
return true;
}
if (choice.Kind == PetAutomationActionKind.Refill)
_nextRefillAt = now + RefusalRetrySeconds;
else
_nextSummonAt = now + RefusalRetrySeconds;
status = result.Notice
?? $"Combat pet action refused: {result.Status}";
return true;
}
internal static PetAutomationChoice Select(
IReadOnlyList<PluginInventoryItem> items,
IReadOnlyList<PluginCombatTarget> targets,
ICharacterInfo character,
CombatSettings settings,
int activeOwnedPetCount,
bool allowRefill,
bool allowSummon)
{
if (!settings.SummonPets || activeOwnedPetCount > 0)
return PetAutomationChoice.None;
float range = settings.PetRangeMode == PetRangeMode.Custom
? settings.PetCustomRange
: settings.MaximumRange;
int density = Math.Max(1, settings.PetMonsterDensity);
var eligible = new List<(PluginCombatTarget Target, ResolvedMonsterRule Rule)>();
foreach (PluginCombatTarget target in targets)
{
if (target.Distance > range)
continue;
ResolvedMonsterRule rule = settings.ResolveRule(target);
if (rule.Priority < 0
|| rule.Actions.PetDamageType == MonsterDamageType.None)
{
continue;
}
eligible.Add((target, rule));
}
if (eligible.Count < density)
return PetAutomationChoice.None;
eligible.Sort(static (left, right) =>
{
int priority = right.Rule.Priority.CompareTo(left.Rule.Priority);
return priority != 0
? priority
: left.Target.Distance.CompareTo(right.Target.Distance);
});
(PluginCombatTarget selectedTarget, ResolvedMonsterRule targetRule) = eligible[0];
MonsterDamageType desired = ResolveDesiredDamage(targetRule.Actions);
PluginInventoryItem? device = SelectDevice(
items,
character,
desired,
settings,
allowFallback: targetRule.Actions.PetDamageType
== MonsterDamageType.PlayerAuto);
if (device is not { } selected)
return PetAutomationChoice.None;
int refillThreshold = Math.Max(0, settings.PetRefillCountNormal);
if (allowRefill
&& selected.MaximumStructure > 0
&& selected.Structure <= refillThreshold
&& selected.Structure < selected.MaximumStructure
&& FindSpirit(items) is { } spirit)
{
return new PetAutomationChoice(
PetAutomationActionKind.Refill,
selected,
spirit,
selectedTarget,
desired);
}
if (!allowSummon || selected.Structure <= 0)
return PetAutomationChoice.None;
return new PetAutomationChoice(
PetAutomationActionKind.Summon,
selected,
default,
selectedTarget,
desired);
}
private static MonsterDamageType ResolveDesiredDamage(
MonsterRuleActions actions)
{
if (actions.PetDamageType != MonsterDamageType.PlayerAuto)
return actions.PetDamageType;
return actions.DamageType is
MonsterDamageType.Bludgeon or MonsterDamageType.Acid
or MonsterDamageType.Fire or MonsterDamageType.Cold
or MonsterDamageType.Electric
? actions.DamageType
: MonsterDamageType.Auto;
}
private static PluginInventoryItem? SelectDevice(
IReadOnlyList<PluginInventoryItem> items,
ICharacterInfo character,
MonsterDamageType desired,
CombatSettings settings,
bool allowFallback)
{
PluginInventoryItem? exact = null;
PluginInventoryItem? fallback = null;
foreach (PluginInventoryItem item in items)
{
if (!settings.CombatItemObjectIds.Contains(item.ObjectId)
&& !settings.CombatItemNames.Contains(item.Name))
{
continue;
}
if (!item.IsPetDevice || !CanUse(item, character))
continue;
MonsterDamageType damage = PetDeviceCatalog.DamageType(
item.WeenieClassId);
if (fallback is null || Better(item, fallback.Value))
fallback = item;
if (desired != MonsterDamageType.Auto && damage != desired)
continue;
if (exact is null || Better(item, exact.Value))
exact = item;
}
if (exact is not null)
return exact;
return desired == MonsterDamageType.Auto || allowFallback
? fallback
: null;
}
private static bool CanUse(
in PluginInventoryItem item,
ICharacterInfo character)
{
if (item.SummoningMastery != 0
&& item.SummoningMastery != character.SummoningMastery)
{
return false;
}
if (item.UseRequiresSkill == 0)
return true;
if (!character.TryGetSkill((uint)item.UseRequiresSkill, out PluginSkillInfo skill)
|| skill.Current < item.UseRequiresSkillLevel)
{
return false;
}
return item.UseRequiresSkillSpecialized == 0
|| skill.Training == PluginSkillTraining.Specialized;
}
private static bool Better(
in PluginInventoryItem candidate,
in PluginInventoryItem incumbent)
{
int candidateRating = candidate.GearDamage
+ candidate.GearCriticalChance
+ candidate.GearCriticalDamage;
int incumbentRating = incumbent.GearDamage
+ incumbent.GearCriticalChance
+ incumbent.GearCriticalDamage;
if (candidate.UseRequiresSkillLevel != incumbent.UseRequiresSkillLevel)
return candidate.UseRequiresSkillLevel > incumbent.UseRequiresSkillLevel;
if (candidateRating != incumbentRating)
return candidateRating > incumbentRating;
if (candidate.Structure != incumbent.Structure)
return candidate.Structure > incumbent.Structure;
return candidate.ObjectId < incumbent.ObjectId;
}
private static PluginInventoryItem? FindSpirit(
IReadOnlyList<PluginInventoryItem> items)
{
foreach (PluginInventoryItem item in items)
{
if (item.WeenieClassId == PetDeviceCatalog.EncapsulatedSpiritWeenieClassId
&& item.StackSize > 0)
{
return item;
}
}
return null;
}
private void ObserveCompletion(
PluginItemUseCompletion completion,
double now,
out string? status)
{
status = null;
if (completion.Revision == 0
|| completion.Revision == _observedCompletionRevision)
{
return;
}
_observedCompletionRevision = completion.Revision;
if (_pendingSourceId == 0u
|| completion.SourceObjectId != _pendingSourceId)
{
return;
}
PetAutomationActionKind completed = _pendingKind;
_pendingSourceId = 0u;
_pendingKind = PetAutomationActionKind.None;
if (completed == PetAutomationActionKind.Summon)
_nextSummonAt = now + RetailPetCooldownSeconds;
else
_nextRefillAt = now + RefusalRetrySeconds;
status = completion.IsSuccess
? completed == PetAutomationActionKind.Summon
? "Combat pet summoned"
: "Combat pet refilled"
: $"Combat pet failed (0x{completion.WeenieError:X})";
}
}

View file

@ -0,0 +1,51 @@
namespace AcDream.Plugins.MossTank;
/// <summary>
/// End-of-retail combat-pet device element table. The device WCIDs and damage
/// types are the server content mapping consumed by PetDevice; keeping the data
/// here lets MossTank implement VTank's PetDmg column without guessing from an
/// item's localized name.
/// </summary>
internal static class PetDeviceCatalog
{
public const uint EncapsulatedSpiritWeenieClassId = 49485u;
public static MonsterDamageType DamageType(uint deviceWeenieClassId) =>
deviceWeenieClassId switch
{
48878u or 48880u or 48882u or 48884u or 48886u or 48888u or 48890u => MonsterDamageType.Bludgeon,
48972u or 49213u or 49214u or 49215u or 49216u or 49217u or 49218u or 49219u
or 49234u or 49235u or 49236u or 49237u or 49238u or 49239u or 49261u or 49262u
or 49263u or 49264u or 49265u or 49266u or 49267u or 49282u or 49283u or 49284u
or 49285u or 49286u or 49287u or 49288u or 49310u or 49311u or 49312u or 49313u
or 49314u or 49315u or 49316u or 49338u or 49339u or 49340u or 49341u or 49342u
or 49343u or 49344u or 49366u or 49367u or 49368u or 49369u or 49370u or 49371u
or 49372u or 49421u or 49422u or 49423u or 49424u or 49425u or 49426u or 49427u
or 49524u or 49525u or 49526u or 49527u or 49528u or 49529u or 49530u => MonsterDamageType.Acid,
48942u or 48944u or 48945u or 48946u or 48947u or 48948u or 48956u or 48957u
or 48959u or 48961u or 48963u or 48965u or 48967u or 48969u or 49247u or 49248u
or 49249u or 49250u or 49251u or 49252u or 49253u or 49296u or 49297u or 49298u
or 49299u or 49300u or 49301u or 49302u or 49324u or 49325u or 49326u or 49327u
or 49328u or 49329u or 49330u or 49352u or 49353u or 49354u or 49355u or 49356u
or 49357u or 49358u or 49380u or 49381u or 49382u or 49383u or 49384u or 49385u
or 49386u or 49435u or 49436u or 49437u or 49438u or 49439u or 49440u or 49441u
or 49531u or 49532u or 49533u or 49534u or 49535u or 49536u or 49537u => MonsterDamageType.Fire,
49212u or 49227u or 49228u or 49229u or 49230u or 49231u or 49232u or 49233u
or 49254u or 49255u or 49256u or 49257u or 49258u or 49259u or 49260u or 49275u
or 49276u or 49277u or 49278u or 49279u or 49280u or 49281u or 49303u or 49304u
or 49305u or 49306u or 49307u or 49308u or 49309u or 49331u or 49332u or 49333u
or 49334u or 49335u or 49336u or 49337u or 49359u or 49360u or 49361u or 49362u
or 49363u or 49364u or 49365u or 49387u or 49388u or 49389u or 49390u or 49391u
or 49392u or 49442u or 49443u or 49444u or 49445u or 49446u or 49447u or 49448u
or 49538u or 49539u or 49540u or 49541u or 49542u or 49543u or 49544u => MonsterDamageType.Cold,
49220u or 49221u or 49222u or 49223u or 49224u or 49225u or 49226u or 49240u
or 49241u or 49242u or 49243u or 49244u or 49245u or 49246u or 49268u or 49269u
or 49270u or 49271u or 49272u or 49273u or 49274u or 49289u or 49290u or 49291u
or 49292u or 49293u or 49294u or 49295u or 49317u or 49318u or 49319u or 49320u
or 49321u or 49322u or 49323u or 49345u or 49346u or 49347u or 49348u or 49349u
or 49350u or 49351u or 49373u or 49374u or 49375u or 49376u or 49377u or 49378u
or 49379u or 49428u or 49429u or 49430u or 49431u or 49432u or 49433u or 49434u
or 49545u or 49546u or 49547u or 49548u or 49549u or 49550u or 49551u => MonsterDamageType.Electric,
_ => MonsterDamageType.Auto,
};
}

View file

@ -0,0 +1,247 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// UtilityBelt-compatible named-profile item giver. It classifies a stable
/// inventory snapshot up front and then submits exactly one canonical give at
/// a time, advancing only after the server completion or the owned-object view
/// confirms that the item left inventory.
/// </summary>
internal sealed class ProfileGiveController
{
private const double GiveTimeoutSeconds = 10d;
private const int MaximumAttemptsPerItem = 5;
private static readonly PluginItemProperties EmptyProperties = new(
new Dictionary<uint, int>(),
new Dictionary<uint, long>(),
new Dictionary<uint, bool>(),
new Dictionary<uint, double>(),
new Dictionary<uint, string>(),
new Dictionary<uint, uint>(),
new Dictionary<uint, uint>());
private readonly IPluginHost _host;
private readonly MossTankLootProfileStore _profiles;
private readonly Queue<uint> _pending = new();
private uint _targetObjectId;
private uint _waitingObjectId;
private long _completionRevision;
private double _waitingSeconds;
private int _attempts;
private int _given;
private string _profileName = string.Empty;
private string _targetName = string.Empty;
public ProfileGiveController(
IPluginHost host,
MossTankLootProfileStore profiles)
{
_host = host ?? throw new ArgumentNullException(nameof(host));
_profiles = profiles ?? throw new ArgumentNullException(nameof(profiles));
}
public bool IsRunning { get; private set; }
public string Status { get; private set; } = "Item giver idle.";
public bool TryStart(string? profileName, string? targetName)
{
if (IsRunning || !_host.Automation.IsAvailable)
return false;
string requestedProfile = profileName?.Trim() ?? string.Empty;
string requestedTarget = targetName?.Trim() ?? string.Empty;
PluginWorldObject target = _host.Automation.Objects.CaptureObjects()
.Where(obj => obj.ObjectClass is PluginObjectClass.Player
or PluginObjectClass.Npc)
.Where(obj => obj.Name.Equals(
requestedTarget,
StringComparison.OrdinalIgnoreCase))
.Where(obj => obj.ObjectId != _host.Automation.Character.ObjectId)
.OrderBy(obj => DistanceFromPlayer(obj))
.ThenBy(static obj => obj.ObjectId)
.FirstOrDefault();
if (target.ObjectId == 0u)
{
Status = $"Item giver target not found: {requestedTarget}.";
return false;
}
var rules = new List<LootRule>();
if (!_profiles.TryLoadNamed(requestedProfile, rules))
{
Status = $"Item giver profile not found: {requestedProfile}.";
return false;
}
IReadOnlyList<PluginInventoryItem> owned =
_host.Automation.Items.CaptureOwnedItems();
_pending.Clear();
foreach (PluginInventoryItem item in owned
.Where(static item => !item.IsEquipped && item.WielderObjectId == 0u)
.OrderBy(static item => item.ObjectId))
{
PluginItemProperties properties = _host.Automation.Items
.TryCaptureProperties(item.ObjectId, out PluginItemProperties value)
? value
: EmptyProperties;
if (MatchesGiveProfile(item, properties, rules))
_pending.Enqueue(item.ObjectId);
}
_targetObjectId = target.ObjectId;
_profileName = requestedProfile;
_targetName = target.Name;
_waitingObjectId = 0u;
_attempts = 0;
_given = 0;
_waitingSeconds = 0d;
IsRunning = true;
Status = _pending.Count == 0
? $"No items match {_profileName}."
: $"Giving {_pending.Count} item(s) to {_targetName}.";
return true;
}
public bool Tick(double elapsedSeconds, bool canAct)
{
if (!IsRunning)
return false;
if (!_host.Automation.IsAvailable
|| !_host.Automation.Objects.TryGet(
_targetObjectId,
out PluginWorldObject target)
|| target.ObjectClass is not (PluginObjectClass.Player
or PluginObjectClass.Npc))
{
Stop("Item giver stopped: target vanished.");
return false;
}
if (_waitingObjectId != 0u)
{
_waitingSeconds += Math.Max(0d, elapsedSeconds);
PluginInventoryCompletion completion =
_host.Automation.Items.LastInventoryCompletion;
bool itemStillOwned = _host.Automation.Items.CaptureOwnedItems()
.Any(item => item.ObjectId == _waitingObjectId);
if (!itemStillOwned
|| (completion.Revision > _completionRevision
&& completion.Kind == PluginInventoryCommandKind.Give
&& completion.SourceObjectId == _waitingObjectId))
{
if (!itemStillOwned || completion.IsSuccess)
_given++;
_pending.Dequeue();
_waitingObjectId = 0u;
_attempts = 0;
_waitingSeconds = 0d;
}
else if (_waitingSeconds >= GiveTimeoutSeconds)
{
if (_attempts >= MaximumAttemptsPerItem)
{
_pending.Dequeue();
_waitingObjectId = 0u;
_attempts = 0;
_waitingSeconds = 0d;
}
else
{
_waitingObjectId = 0u;
_waitingSeconds = 0d;
}
}
return true;
}
if (_pending.Count == 0)
{
Stop($"Item giver finished: {_given} item(s) given to {_targetName}.");
return false;
}
if (!canAct || _host.Automation.Items.IsBusy)
return true;
uint objectId = _pending.Peek();
if (!_host.Automation.Items.CaptureOwnedItems()
.Any(item => item.ObjectId == objectId))
{
_pending.Dequeue();
return true;
}
long baselineRevision =
_host.Automation.Items.LastInventoryCompletion.Revision;
PluginItemCommandResult result = _host.Automation.Items.Give(
objectId,
_targetObjectId);
if (result.Accepted)
{
_waitingObjectId = objectId;
_completionRevision = baselineRevision;
_waitingSeconds = 0d;
_attempts++;
Status = $"Giving item {_given + 1} to {_targetName}…";
}
else if (result.Status is PluginItemCommandStatus.InvalidItem
or PluginItemCommandStatus.InvalidTarget
or PluginItemCommandStatus.Refused
or PluginItemCommandStatus.Unavailable)
{
_pending.Dequeue();
_attempts = 0;
}
return true;
}
public void Reset()
{
_pending.Clear();
_targetObjectId = 0u;
_waitingObjectId = 0u;
_completionRevision = 0;
_waitingSeconds = 0d;
_attempts = 0;
_given = 0;
IsRunning = false;
Status = "Item giver idle.";
}
private bool MatchesGiveProfile(
in PluginInventoryItem item,
in PluginItemProperties properties,
IReadOnlyList<LootRule> rules)
{
foreach (LootRule rule in rules)
{
if (!rule.IsMatch(item, properties, _host, out _))
continue;
return rule.Action is LootAction.Keep or LootAction.KeepUpTo;
}
return false;
}
private double DistanceFromPlayer(in PluginWorldObject target)
{
PluginNavigationSnapshot player = _host.Automation.Navigation.Snapshot;
if (!player.IsAvailable || !target.HasPosition)
return double.MaxValue;
double dx = target.Position.NorthSouth - player.Position.NorthSouth;
double dy = target.Position.EastWest - player.Position.EastWest;
return Math.Sqrt((dx * dx) + (dy * dy));
}
private void Stop(string status)
{
_pending.Clear();
_targetObjectId = 0u;
_waitingObjectId = 0u;
_completionRevision = 0;
_waitingSeconds = 0d;
_attempts = 0;
IsRunning = false;
Status = status;
}
}

View file

@ -0,0 +1,73 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// VTank's BlacklistedSpellComps gate over the component metadata projected by
/// the host. The legacy setting serializes component id/name pairs, so both
/// forms are accepted for imported profiles.
/// </summary>
internal static class SpellComponentPolicy
{
public static bool UsesBlacklistedComponent(
ISpellCatalog catalog,
in PluginSpellInfo spell,
string setting)
{
if (string.IsNullOrWhiteSpace(setting)
|| spell.FormulaComponentIds.Count == 0)
{
return false;
}
foreach (uint componentId in spell.FormulaComponentIds)
{
if (ContainsNumber(setting, componentId))
return true;
if (!catalog.TryGetComponent(
componentId,
out PluginSpellComponentInfo component))
{
continue;
}
if (component.Name.Length != 0
&& setting.Contains(
component.Name,
StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (ContainsNumber(setting, component.WeenieClassId))
return true;
}
return false;
}
private static bool ContainsNumber(string setting, uint value)
{
if (value == 0u)
return false;
string decimalText = value.ToString(CultureInfo.InvariantCulture);
string hexText = value.ToString("X", CultureInfo.InvariantCulture);
return ContainsDelimited(setting, decimalText)
|| setting.Contains("0x" + hexText, StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsDelimited(string text, string token)
{
int start = 0;
while ((start = text.IndexOf(
token,
start,
StringComparison.OrdinalIgnoreCase)) >= 0)
{
int end = start + token.Length;
bool left = start == 0 || !char.IsDigit(text[start - 1]);
bool right = end == text.Length || !char.IsDigit(text[end]);
if (left && right)
return true;
start = end;
}
return false;
}
}

View file

@ -2,91 +2,184 @@ using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>What MossTank wants to do about the character's vitals right now.</summary>
internal enum VitalKind
{
Health = 2,
Stamina = 4,
Mana = 6,
}
/// <summary>What the legacy conversion-only helper wants to do.</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>
/// <summary>
/// VTank's nine <c>Recharge-*</c> sliders and its recharge-handler options.
/// Values are normalized 0..1 at the plugin/UI seam; VTank stores percentages.
/// </summary>
public sealed class VitalSettings
{
/// <summary>
/// Whether to convert vitals at all. VTank does this by default through its
/// Recharge-* thresholds, but it is surprising the first time a buff pass
/// spends your stamina, so it is worth being able to turn off.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>Convert stamina to mana below this fraction of max mana.</summary>
public double ManaFloor { get; set; } = 0.50;
// defaultsettings.usd, verbatim.
public double NormalHealth { get; set; } = 0.75;
public double NormalStamina { get; set; } = 0.50;
public double NormalMana { get; set; } = 0.50;
public double NoTargetHealth { get; set; } = 0.01;
public double NoTargetStamina { get; set; } = 0.01;
public double NoTargetMana { get; set; } = 0.01;
public double HelperHealth { get; set; } = 0.20;
public double HelperStamina { get; set; } = 0.01;
public double HelperMana { get; set; } = 0.01;
public float HelperHealthDistance { get; set; } = 59.6f;
public float HelperStaminaDistance { get; set; } = 59.6f;
public float HelperManaDistance { get; set; } = 32f;
/// <summary>Stop converting once mana is back above this fraction.</summary>
public double ManaTarget { get; set; } = 0.85;
public bool HelpOthers { get; set; } = true;
public bool UseHealersHeart { get; set; } = true;
public double RechargeBoostTimeSeconds { get; set; } = 5d;
public int RechargeBoostAmount { get; set; } = 40;
public bool ClearLevelBoostFlagOnCast { get; set; } = true;
public int DropToPeaceModeRetryCount { get; set; } = 34;
public string RechargeHandlerSet { get; set; } = "RechargeHandlerSet";
public bool UseKitsInMagicMode { get; set; } = true;
public bool GoToPeaceModeToUseKits { get; set; }
public int MinimumHealKitSuccessChance { get; set; } = 95;
public double StaminaToHealthMultiplier { get; set; } = 1.9;
public double ManaToHealthMultiplier { get; set; } = 2.8;
public bool CastDispelSelf { get; set; }
public bool UseDispelItems { get; set; }
public bool UseDispelDrum { get; set; }
/// <summary>Refuse to drain stamina below this fraction — the conversion
/// takes half your stamina, and stranding the character at zero is worse
/// than being short of mana.</summary>
public double StaminaFloor { get; set; } = 0.35;
// Compatibility aliases for the first MossTank prototype. Keeping them
// avoids breaking plugin-side callers while the implementation now follows
// VTank's actual nine-threshold model.
public double ManaFloor
{
get => NormalMana;
set => NormalMana = Clamp(value);
}
public double ManaTarget
{
get => NormalMana;
set => NormalMana = Clamp(value);
}
public double StaminaFloor
{
get => NormalStamina;
set => NormalStamina = Clamp(value);
}
internal double Threshold(VitalKind vital, bool noTarget) => vital switch
{
VitalKind.Health => noTarget
? Math.Max(NormalHealth, NoTargetHealth)
: NormalHealth,
VitalKind.Stamina => noTarget
? Math.Max(NormalStamina, NoTargetStamina)
: NormalStamina,
VitalKind.Mana => noTarget
? Math.Max(NormalMana, NoTargetMana)
: NormalMana,
_ => 0d,
};
internal double NormalThreshold(VitalKind vital) => vital switch
{
VitalKind.Health => NormalHealth,
VitalKind.Stamina => NormalStamina,
VitalKind.Mana => NormalMana,
_ => 0d,
};
private static double Clamp(double value) => Math.Clamp(value, 0d, 1d);
}
/// <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>
/// <summary>Pure retail threshold and spell-selection policy.</summary>
public static class VitalPlan
{
public const string HealSelfStem = "Heal Self";
public const string StaminaToManaStem = "Stamina to Mana";
public const string RevitalizeStem = "Revitalize";
public static VitalAction Decide(ICharacterInfo character, VitalSettings settings)
internal static VitalKind? DecideNeed(
ICharacterInfo character,
VitalSettings settings,
bool noTarget,
int healthCurrentAdjustment = 0,
int staminaCurrentAdjustment = 0,
int manaCurrentAdjustment = 0)
{
if (!settings.Enabled)
return VitalAction.None;
return null;
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)
// cr.cs checks in this exact order.
foreach (VitalKind vital in new[]
{
VitalKind.Health,
VitalKind.Stamina,
VitalKind.Mana,
})
{
return stamina > settings.StaminaFloor
? VitalAction.StaminaToMana
: VitalAction.Revitalize;
(uint current, uint maximum) = Read(character, vital);
if (maximum == 0u)
continue;
int adjustment = vital switch
{
VitalKind.Health => healthCurrentAdjustment,
VitalKind.Stamina => staminaCurrentAdjustment,
VitalKind.Mana => manaCurrentAdjustment,
_ => 0,
};
current = adjustment <= 0
? current
: (uint)Math.Max(0L, (long)current - adjustment);
if ((double)current / maximum < settings.Threshold(vital, noTarget))
return vital;
}
return null;
}
return VitalAction.None;
internal static bool IsBelowNormal(
ICharacterInfo character,
VitalSettings settings,
VitalKind vital)
{
(uint current, uint maximum) = Read(character, vital);
return maximum != 0u
&& (double)current / maximum < settings.NormalThreshold(vital);
}
internal static int Percent(ICharacterInfo character, VitalKind vital)
{
(uint current, uint maximum) = Read(character, vital);
return maximum == 0u ? 100 : (int)(100u * current / maximum);
}
/// <summary>
/// The strongest castable spell whose name contains <paramref name="stem"/>.
/// Compatibility helper for the original MossTank mana-conversion tests.
/// The executable controller now uses <see cref="DecideNeed"/>.
/// </summary>
public static VitalAction Decide(ICharacterInfo character, VitalSettings settings)
{
if (!settings.Enabled || character.MaxMana == 0 || character.MaxStamina == 0)
return VitalAction.None;
double mana = (double)character.CurrentMana / character.MaxMana;
if (mana >= settings.NormalMana)
return VitalAction.None;
double stamina = (double)character.CurrentStamina / character.MaxStamina;
return stamina > settings.NormalStamina
? VitalAction.StaminaToMana
: VitalAction.Revitalize;
}
/// <summary>The strongest castable learned spell whose name contains a stem.</summary>
public static bool TryFind(
IReadOnlyList<PluginSpellInfo> known,
string stem,
@ -96,7 +189,6 @@ public static class VitalPlan
{
pick = default;
bool found = false;
foreach (PluginSpellInfo spell in known)
{
if (spell.Name.IndexOf(stem, StringComparison.OrdinalIgnoreCase) < 0)
@ -107,7 +199,9 @@ public static class VitalPlan
{
continue;
}
if (!found || spell.Tier > pick.Tier)
if (!found
|| spell.Quality > pick.Quality
|| (spell.Quality == pick.Quality && spell.Tier > pick.Tier))
{
pick = spell;
found = true;
@ -116,6 +210,13 @@ public static class VitalPlan
return found;
}
private static double Fraction(uint current, uint max) =>
max == 0 ? 1.0 : (double)current / max;
private static (uint Current, uint Maximum) Read(
ICharacterInfo character,
VitalKind vital) => vital switch
{
VitalKind.Health => (character.CurrentHealth, character.MaxHealth),
VitalKind.Stamina => (character.CurrentStamina, character.MaxStamina),
VitalKind.Mana => (character.CurrentMana, character.MaxMana),
_ => (0u, 0u),
};
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,164 @@
using System.Globalization;
using System.Reflection;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
internal enum VtankPrismaticAmmoPolicy
{
Any,
NoPrismatic,
ForcePrismatic,
}
internal readonly record struct VtankAmmunitionOption(
string Name,
int LauncherType,
int WieldRequirement,
int Element,
int Quality,
int SpecialMask,
uint SecondarySkill,
int SecondaryRequirement);
/// <summary>
/// The complete 120-row AmmunitionOptions table from VTank's official
/// GameInfoDB. Selection retains bv.cs ordering and equal-quality replacement.
/// </summary>
internal static class VtankAmmunitionDatabase
{
private const string ResourceSuffix = ".VtankAmmunitionOptions.tsv";
private static readonly Lazy<VtankAmmunitionOption[]> Loaded = new(Load);
public static IReadOnlyList<VtankAmmunitionOption> Options => Loaded.Value;
public static int LauncherType(uint ammoType) => ammoType switch
{
0x001u or 0x008u or 0x040u => 5,
0x002u or 0x010u or 0x080u => 6,
0x004u or 0x020u or 0x100u => 7,
_ => 0,
};
public static VtankAmmunitionOption? Select(
int launcherType,
MonsterDamageType damage,
VtankPrismaticAmmoPolicy prismatic,
int enabledSpecialMask,
ICharacterInfo character,
Func<string, bool> isAvailable)
{
ArgumentNullException.ThrowIfNull(character);
ArgumentNullException.ThrowIfNull(isAvailable);
int desiredElement = Element(damage);
if (launcherType == 0 || desiredElement < 0)
return null;
VtankAmmunitionOption? best = null;
int bestQuality = int.MinValue;
foreach (VtankAmmunitionOption option in Loaded.Value)
{
if (option.LauncherType != launcherType)
continue;
int quality = option.Quality;
if (prismatic == VtankPrismaticAmmoPolicy.ForcePrismatic
&& option.Element != 100)
{
quality -= 1000;
}
if (option.Element != desiredElement)
{
if (option.Element != 100)
continue;
if (prismatic == VtankPrismaticAmmoPolicy.NoPrismatic)
quality -= 1000;
}
if (quality < bestQuality
|| !MeetsRequirements(option, character)
|| (option.SpecialMask != 0
&& (option.SpecialMask & enabledSpecialMask) == 0)
|| !isAvailable(option.Name))
{
continue;
}
bestQuality = quality;
best = option;
}
return best;
}
private static bool MeetsRequirements(
in VtankAmmunitionOption option,
ICharacterInfo character)
{
if (option.WieldRequirement > 0)
{
if (!character.TryGetSkill(47u, out PluginSkillInfo missile)
|| missile.Training == PluginSkillTraining.Untrained
|| missile.Base < option.WieldRequirement)
{
return false;
}
}
if (option.SecondarySkill == 0u || option.SecondaryRequirement == 0)
return true;
return character.TryGetSkill(
option.SecondarySkill,
out PluginSkillInfo secondary)
&& secondary.Training != PluginSkillTraining.Untrained
&& secondary.Current >= option.SecondaryRequirement;
}
private static int Element(MonsterDamageType damage) => damage switch
{
MonsterDamageType.Pierce => 0,
MonsterDamageType.Bludgeon => 1,
MonsterDamageType.Slash => 2,
MonsterDamageType.Acid => 3,
MonsterDamageType.Electric => 4,
MonsterDamageType.Cold => 5,
MonsterDamageType.Fire => 6,
// ForcePrismatic still needs a concrete comparison element for the
// official fallback scoring; Pierce is VTank's seed value.
MonsterDamageType.Prismatic => 0,
_ => -1,
};
private static VtankAmmunitionOption[] Load()
{
Assembly assembly = typeof(VtankAmmunitionDatabase).Assembly;
string resource = assembly.GetManifestResourceNames().Single(
static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal));
using Stream stream = assembly.GetManifestResourceStream(resource)
?? throw new InvalidOperationException(
"The embedded VTank AmmunitionOptions table is missing.");
using var reader = new StreamReader(stream);
var all = new List<VtankAmmunitionOption>(120);
while (reader.ReadLine() is { } line)
{
if (line.Length == 0 || line[0] == '#')
continue;
string[] fields = line.Split('\t');
if (fields.Length != 8)
throw new InvalidDataException("Malformed VTank ammunition row.");
all.Add(new VtankAmmunitionOption(
fields[0],
Parse(fields[1]),
Parse(fields[2]),
Parse(fields[3]),
Parse(fields[4]),
Parse(fields[5]),
(uint)Parse(fields[6]),
Parse(fields[7])));
}
if (all.Count != 120)
{
throw new InvalidDataException(
$"Expected 120 official VTank ammunition rows, found {all.Count}.");
}
return [.. all];
}
private static int Parse(string value) =>
int.Parse(value, CultureInfo.InvariantCulture);
}

View file

@ -0,0 +1,121 @@
# AmmoName LauncherType WieldReq Element Quality Special WieldReq2Skill WieldReq2Value
Barbed Quarrel 6 0 0 4 0 0 0
Greater Barbed Quarrel 6 0 0 9 0 0 0
Deadly Barbed Quarrel 6 230 0 22 0 0 0
Blunt Quarrel 6 0 1 5 0 0 0
Greater Blunt Quarrel 6 0 1 10 0 0 0
Deadly Blunt Quarrel 6 230 1 20 0 0 0
Armor Piercing Quarrel 6 0 0 5 0 0 0
Greater Armor Piercing Quarrel 6 0 0 10 0 0 0
Deadly Armor Piercing Quarrel 6 230 0 23 0 0 0
Frog Crotch Quarrel 6 0 2 5 0 0 0
Greater Frog Crotch Quarrel 6 0 2 10 0 0 0
Deadly Frog Crotch Quarrel 6 230 2 23 0 0 0
Fire Quarrel 6 0 6 5 0 0 0
Greater Fire Quarrel 6 0 6 10 0 0 0
Deadly Fire Quarrel 6 230 6 20 0 0 0
Lightning Quarrel 6 0 4 5 0 0 0
Greater Lightning Quarrel 6 0 4 10 0 0 0
Deadly Lightning Quarrel 6 230 4 20 0 0 0
Acid Quarrel 6 0 3 5 0 0 0
Greater Acid Quarrel 6 0 3 10 0 0 0
Deadly Acid Quarrel 6 230 3 20 0 0 0
Frost Quarrel 6 0 5 5 0 0 0
Greater Frost Quarrel 6 0 5 10 0 0 0
Deadly Frost Quarrel 6 230 5 20 0 0 0
Barbed Atlatl Dart 7 0 0 4 0 0 0
Greater Barbed Atlatl Dart 7 0 0 9 0 0 0
Deadly Barbed Atlatl Dart 7 230 0 22 0 0 0
Blunt Atlatl Dart 7 0 1 5 0 0 0
Greater Blunt Atlatl Dart 7 0 1 10 0 0 0
Deadly Blunt Atlatl Dart 7 230 1 20 0 0 0
Armor Piercing Atlatl Dart 7 0 0 5 0 0 0
Greater Armor Piercing Atlatl Dart 7 0 0 10 0 0 0
Deadly Armor Piercing Atlatl Dart 7 230 0 23 0 0 0
Frog Crotch Atlatl Dart 7 0 2 5 0 0 0
Greater Frog Crotch Atlatl Dart 7 0 2 10 0 0 0
Deadly Frog Crotch Atlatl Dart 7 230 2 23 0 0 0
Fire Atlatl Dart 7 0 6 5 0 0 0
Greater Fire Atlatl Dart 7 0 6 10 0 0 0
Deadly Fire Atlatl Dart 7 230 6 20 0 0 0
Lightning Atlatl Dart 7 0 4 5 0 0 0
Greater Lightning Atlatl Dart 7 0 4 10 0 0 0
Deadly Lightning Atlatl Dart 7 230 4 20 0 0 0
Acid Atlatl Dart 7 0 3 5 0 0 0
Greater Acid Atlatl Dart 7 0 3 10 0 0 0
Deadly Acid Atlatl Dart 7 230 3 20 0 0 0
Frost Atlatl Dart 7 0 5 5 0 0 0
Greater Frost Atlatl Dart 7 0 5 10 0 0 0
Deadly Frost Atlatl Dart 7 230 5 20 0 0 0
Barbed Arrow 5 0 0 4 0 0 0
Greater Barbed Arrow 5 0 0 9 0 0 0
Deadly Barbed Arrow 5 230 0 22 0 0 0
Blunt Arrow 5 0 1 5 0 0 0
Greater Blunt Arrow 5 0 1 10 0 0 0
Deadly Blunt Arrow 5 230 1 20 0 0 0
Armor Piercing Arrow 5 0 0 5 0 0 0
Greater Armor Piercing Arrow 5 0 0 10 0 0 0
Deadly Armor Piercing Arrow 5 230 0 23 0 0 0
Frog Crotch Arrow 5 0 2 5 0 0 0
Greater Frog Crotch Arrow 5 0 2 10 0 0 0
Deadly Frog Crotch Arrow 5 230 2 23 0 0 0
Fire Arrow 5 0 6 5 0 0 0
Greater Fire Arrow 5 0 6 10 0 0 0
Deadly Fire Arrow 5 230 6 20 0 0 0
Lightning Arrow 5 0 4 5 0 0 0
Greater Lightning Arrow 5 0 4 10 0 0 0
Deadly Lightning Arrow 5 230 4 20 0 0 0
Acid Arrow 5 0 3 5 0 0 0
Greater Acid Arrow 5 0 3 10 0 0 0
Deadly Acid Arrow 5 230 3 20 0 0 0
Frost Arrow 5 0 5 5 0 0 0
Greater Frost Arrow 5 0 5 10 0 0 0
Deadly Frost Arrow 5 230 5 20 0 0 0
Deadly Arrow 5 230 0 20 0 0 0
Deadly Quarrel 6 230 0 20 0 0 0
Deadly Atlatl Dart 7 230 0 20 0 0 0
Deadly Broadhead Arrow 5 230 2 20 0 0 0
Deadly Broadhead Quarrel 6 230 2 20 0 0 0
Deadly Broadhead Atlatl Dart 7 230 2 20 0 0 0
Greater Broadhead Atlatl Dart 7 0 2 8 0 0 0
Greater Broadhead Arrow 5 0 2 8 0 0 0
Greater Broadhead Quarrel 6 0 2 8 0 0 0
Arrow 5 0 0 3 0 0 0
Atlatl Dart 7 0 0 3 0 0 0
Quarrel 6 0 0 3 0 0 0
Broadhead Arrow 5 0 2 3 0 0 0
Broadhead Quarrel 6 0 2 3 0 0 0
Broadhead Atlatl Dart 7 0 2 3 0 0 0
Raider Lightning Bolt 6 270 4 30 1 0 0
Raider Lightning Atlatl Dart 7 270 4 30 1 0 0
Raider Lightning Arrow 5 270 4 30 1 0 0
Spectral Chill Arrow 5 270 5 30 2 0 0
Spectral Chill Bolt 6 270 5 30 2 0 0
Spectral Chill Atlatl Dart 7 270 5 30 2 0 0
Olthoi Acid Arrow 5 270 3 30 2 0 0
Olthoi Acid Bolt 6 270 3 30 2 0 0
Olthoi Acid Atlatl Dart 7 270 3 30 2 0 0
Greater Deadly Blunt Arrow 5 270 1 30 0 0 0
Greater Deadly Blunt Quarrel 6 270 1 30 0 0 0
Greater Deadly Blunt Atlatl Dart 7 270 1 30 0 0 0
Gear Blade Slashing Arrow 5 270 2 30 2 0 0
Gear Blade Slashing Bolt 6 270 2 30 2 0 0
Gear Blade Slashing Atlatl Dart 7 270 2 30 2 0 0
Burning Sands Atlatl Dart 7 270 6 30 2 0 0
Burning Sands Bolt 6 270 6 30 2 0 0
Burning Sands Arrow 5 270 6 30 2 0 0
Greater Deadly Armor Piercing Atlatl Dart 7 270 0 33 0 0 0
Greater Deadly Armor Piercing Arrow 5 270 0 33 0 0 0
Greater Deadly Armor Piercing Quarrel 6 270 0 33 0 0 0
Greater Deadly Frog Crotch Atlatl Dart 7 270 2 33 0 0 0
Greater Deadly Frog Crotch Quarrel 6 270 2 33 0 0 0
Greater Deadly Frog Crotch Arrow 5 270 2 33 0 0 0
Deadly Prismatic Atlatl Dart 7 300 100 31 0 37 375
Deadly Prismatic Quarrel 6 300 100 31 0 37 375
Deadly Prismatic Arrow 5 300 100 31 0 37 375
Greater Prismatic Atlatl Dart 7 290 100 26 0 37 350
Greater Prismatic Quarrel 6 290 100 26 0 37 350
Greater Prismatic Arrow 5 290 100 26 0 37 350
Prismatic Atlatl Dart 7 250 100 21 0 37 250
Prismatic Quarrel 6 250 100 21 0 37 250
Prismatic Arrow 5 250 100 21 0 37 250
1 # AmmoName LauncherType WieldReq Element Quality Special WieldReq2Skill WieldReq2Value
2 Barbed Quarrel 6 0 0 4 0 0 0
3 Greater Barbed Quarrel 6 0 0 9 0 0 0
4 Deadly Barbed Quarrel 6 230 0 22 0 0 0
5 Blunt Quarrel 6 0 1 5 0 0 0
6 Greater Blunt Quarrel 6 0 1 10 0 0 0
7 Deadly Blunt Quarrel 6 230 1 20 0 0 0
8 Armor Piercing Quarrel 6 0 0 5 0 0 0
9 Greater Armor Piercing Quarrel 6 0 0 10 0 0 0
10 Deadly Armor Piercing Quarrel 6 230 0 23 0 0 0
11 Frog Crotch Quarrel 6 0 2 5 0 0 0
12 Greater Frog Crotch Quarrel 6 0 2 10 0 0 0
13 Deadly Frog Crotch Quarrel 6 230 2 23 0 0 0
14 Fire Quarrel 6 0 6 5 0 0 0
15 Greater Fire Quarrel 6 0 6 10 0 0 0
16 Deadly Fire Quarrel 6 230 6 20 0 0 0
17 Lightning Quarrel 6 0 4 5 0 0 0
18 Greater Lightning Quarrel 6 0 4 10 0 0 0
19 Deadly Lightning Quarrel 6 230 4 20 0 0 0
20 Acid Quarrel 6 0 3 5 0 0 0
21 Greater Acid Quarrel 6 0 3 10 0 0 0
22 Deadly Acid Quarrel 6 230 3 20 0 0 0
23 Frost Quarrel 6 0 5 5 0 0 0
24 Greater Frost Quarrel 6 0 5 10 0 0 0
25 Deadly Frost Quarrel 6 230 5 20 0 0 0
26 Barbed Atlatl Dart 7 0 0 4 0 0 0
27 Greater Barbed Atlatl Dart 7 0 0 9 0 0 0
28 Deadly Barbed Atlatl Dart 7 230 0 22 0 0 0
29 Blunt Atlatl Dart 7 0 1 5 0 0 0
30 Greater Blunt Atlatl Dart 7 0 1 10 0 0 0
31 Deadly Blunt Atlatl Dart 7 230 1 20 0 0 0
32 Armor Piercing Atlatl Dart 7 0 0 5 0 0 0
33 Greater Armor Piercing Atlatl Dart 7 0 0 10 0 0 0
34 Deadly Armor Piercing Atlatl Dart 7 230 0 23 0 0 0
35 Frog Crotch Atlatl Dart 7 0 2 5 0 0 0
36 Greater Frog Crotch Atlatl Dart 7 0 2 10 0 0 0
37 Deadly Frog Crotch Atlatl Dart 7 230 2 23 0 0 0
38 Fire Atlatl Dart 7 0 6 5 0 0 0
39 Greater Fire Atlatl Dart 7 0 6 10 0 0 0
40 Deadly Fire Atlatl Dart 7 230 6 20 0 0 0
41 Lightning Atlatl Dart 7 0 4 5 0 0 0
42 Greater Lightning Atlatl Dart 7 0 4 10 0 0 0
43 Deadly Lightning Atlatl Dart 7 230 4 20 0 0 0
44 Acid Atlatl Dart 7 0 3 5 0 0 0
45 Greater Acid Atlatl Dart 7 0 3 10 0 0 0
46 Deadly Acid Atlatl Dart 7 230 3 20 0 0 0
47 Frost Atlatl Dart 7 0 5 5 0 0 0
48 Greater Frost Atlatl Dart 7 0 5 10 0 0 0
49 Deadly Frost Atlatl Dart 7 230 5 20 0 0 0
50 Barbed Arrow 5 0 0 4 0 0 0
51 Greater Barbed Arrow 5 0 0 9 0 0 0
52 Deadly Barbed Arrow 5 230 0 22 0 0 0
53 Blunt Arrow 5 0 1 5 0 0 0
54 Greater Blunt Arrow 5 0 1 10 0 0 0
55 Deadly Blunt Arrow 5 230 1 20 0 0 0
56 Armor Piercing Arrow 5 0 0 5 0 0 0
57 Greater Armor Piercing Arrow 5 0 0 10 0 0 0
58 Deadly Armor Piercing Arrow 5 230 0 23 0 0 0
59 Frog Crotch Arrow 5 0 2 5 0 0 0
60 Greater Frog Crotch Arrow 5 0 2 10 0 0 0
61 Deadly Frog Crotch Arrow 5 230 2 23 0 0 0
62 Fire Arrow 5 0 6 5 0 0 0
63 Greater Fire Arrow 5 0 6 10 0 0 0
64 Deadly Fire Arrow 5 230 6 20 0 0 0
65 Lightning Arrow 5 0 4 5 0 0 0
66 Greater Lightning Arrow 5 0 4 10 0 0 0
67 Deadly Lightning Arrow 5 230 4 20 0 0 0
68 Acid Arrow 5 0 3 5 0 0 0
69 Greater Acid Arrow 5 0 3 10 0 0 0
70 Deadly Acid Arrow 5 230 3 20 0 0 0
71 Frost Arrow 5 0 5 5 0 0 0
72 Greater Frost Arrow 5 0 5 10 0 0 0
73 Deadly Frost Arrow 5 230 5 20 0 0 0
74 Deadly Arrow 5 230 0 20 0 0 0
75 Deadly Quarrel 6 230 0 20 0 0 0
76 Deadly Atlatl Dart 7 230 0 20 0 0 0
77 Deadly Broadhead Arrow 5 230 2 20 0 0 0
78 Deadly Broadhead Quarrel 6 230 2 20 0 0 0
79 Deadly Broadhead Atlatl Dart 7 230 2 20 0 0 0
80 Greater Broadhead Atlatl Dart 7 0 2 8 0 0 0
81 Greater Broadhead Arrow 5 0 2 8 0 0 0
82 Greater Broadhead Quarrel 6 0 2 8 0 0 0
83 Arrow 5 0 0 3 0 0 0
84 Atlatl Dart 7 0 0 3 0 0 0
85 Quarrel 6 0 0 3 0 0 0
86 Broadhead Arrow 5 0 2 3 0 0 0
87 Broadhead Quarrel 6 0 2 3 0 0 0
88 Broadhead Atlatl Dart 7 0 2 3 0 0 0
89 Raider Lightning Bolt 6 270 4 30 1 0 0
90 Raider Lightning Atlatl Dart 7 270 4 30 1 0 0
91 Raider Lightning Arrow 5 270 4 30 1 0 0
92 Spectral Chill Arrow 5 270 5 30 2 0 0
93 Spectral Chill Bolt 6 270 5 30 2 0 0
94 Spectral Chill Atlatl Dart 7 270 5 30 2 0 0
95 Olthoi Acid Arrow 5 270 3 30 2 0 0
96 Olthoi Acid Bolt 6 270 3 30 2 0 0
97 Olthoi Acid Atlatl Dart 7 270 3 30 2 0 0
98 Greater Deadly Blunt Arrow 5 270 1 30 0 0 0
99 Greater Deadly Blunt Quarrel 6 270 1 30 0 0 0
100 Greater Deadly Blunt Atlatl Dart 7 270 1 30 0 0 0
101 Gear Blade Slashing Arrow 5 270 2 30 2 0 0
102 Gear Blade Slashing Bolt 6 270 2 30 2 0 0
103 Gear Blade Slashing Atlatl Dart 7 270 2 30 2 0 0
104 Burning Sands Atlatl Dart 7 270 6 30 2 0 0
105 Burning Sands Bolt 6 270 6 30 2 0 0
106 Burning Sands Arrow 5 270 6 30 2 0 0
107 Greater Deadly Armor Piercing Atlatl Dart 7 270 0 33 0 0 0
108 Greater Deadly Armor Piercing Arrow 5 270 0 33 0 0 0
109 Greater Deadly Armor Piercing Quarrel 6 270 0 33 0 0 0
110 Greater Deadly Frog Crotch Atlatl Dart 7 270 2 33 0 0 0
111 Greater Deadly Frog Crotch Quarrel 6 270 2 33 0 0 0
112 Greater Deadly Frog Crotch Arrow 5 270 2 33 0 0 0
113 Deadly Prismatic Atlatl Dart 7 300 100 31 0 37 375
114 Deadly Prismatic Quarrel 6 300 100 31 0 37 375
115 Deadly Prismatic Arrow 5 300 100 31 0 37 375
116 Greater Prismatic Atlatl Dart 7 290 100 26 0 37 350
117 Greater Prismatic Quarrel 6 290 100 26 0 37 350
118 Greater Prismatic Arrow 5 290 100 26 0 37 350
119 Prismatic Atlatl Dart 7 250 100 21 0 37 250
120 Prismatic Quarrel 6 250 100 21 0 37 250
121 Prismatic Arrow 5 250 100 21 0 37 250

View file

@ -0,0 +1,81 @@
using System.Globalization;
using System.Reflection;
namespace AcDream.Plugins.MossTank;
internal readonly record struct VtankCraftRecipe(
string FirstItem,
string SecondItem,
string ResultItem,
int ResultCount,
uint RequiredSkill,
int Difficulty,
int Id);
/// <summary>
/// The complete 757-row CraftInteractions table shipped by VTank's official
/// GameInfoDB. Order is significant: VTank walks matching recipes in database
/// order and recursively tries their ingredients.
/// </summary>
internal static class VtankCraftDatabase
{
private const string ResourceSuffix = ".VtankCraftRecipes.tsv";
private static readonly Lazy<Catalog> Loaded = new(Load);
public static IReadOnlyList<VtankCraftRecipe> Recipes => Loaded.Value.All;
public static IReadOnlyList<VtankCraftRecipe> ForResult(string resultName)
{
if (string.IsNullOrWhiteSpace(resultName))
return Array.Empty<VtankCraftRecipe>();
return Loaded.Value.ByResult.TryGetValue(
resultName.Trim(),
out VtankCraftRecipe[]? recipes)
? recipes
: Array.Empty<VtankCraftRecipe>();
}
private static Catalog Load()
{
Assembly assembly = typeof(VtankCraftDatabase).Assembly;
string resource = assembly.GetManifestResourceNames().Single(
static name => name.EndsWith(ResourceSuffix, StringComparison.Ordinal));
using Stream stream = assembly.GetManifestResourceStream(resource)
?? throw new InvalidOperationException(
"The embedded VTank CraftInteractions table is missing.");
using var reader = new StreamReader(stream);
var all = new List<VtankCraftRecipe>(757);
while (reader.ReadLine() is { } line)
{
if (line.Length == 0 || line[0] == '#')
continue;
string[] fields = line.Split('\t');
if (fields.Length != 7)
throw new InvalidDataException("Malformed VTank craft row.");
all.Add(new VtankCraftRecipe(
fields[0],
fields[1],
fields[2],
int.Parse(fields[3], CultureInfo.InvariantCulture),
uint.Parse(fields[4], CultureInfo.InvariantCulture),
int.Parse(fields[5], CultureInfo.InvariantCulture),
int.Parse(fields[6], CultureInfo.InvariantCulture)));
}
if (all.Count != 757)
{
throw new InvalidDataException(
$"Expected 757 official VTank craft rows, found {all.Count}.");
}
Dictionary<string, VtankCraftRecipe[]> byResult = all
.GroupBy(static recipe => recipe.ResultItem, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
static group => group.Key,
static group => group.OrderBy(recipe => recipe.Id).ToArray(),
StringComparer.OrdinalIgnoreCase);
return new Catalog(all.ToArray(), byResult);
}
private sealed record Catalog(
VtankCraftRecipe[] All,
Dictionary<string, VtankCraftRecipe[]> ByResult);
}

View file

@ -0,0 +1,758 @@
# Official VTank GameInfoDB CraftInteractions: item1<TAB>item2<TAB>result<TAB>count<TAB>skill<TAB>difficulty<TAB>id
Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Arrowshafts Barbed Arrow 250 37 55 1
Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Arrowshafts Greater Barbed Arrow 250 37 209 2
Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Arrowshafts Deadly Barbed Arrow 250 37 220 3
Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Quarrelshafts Barbed Quarrel 250 37 55 4
Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Quarrelshafts Greater Barbed Quarrel 250 37 209 5
Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Quarrelshafts Deadly Barbed Quarrel 250 37 220 6
Wrapped Bundle of Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Barbed Atlatl Dart 250 37 55 7
Wrapped Bundle of Greater Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Barbed Atlatl Dart 250 37 209 8
Wrapped Bundle of Deadly Barbed Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Barbed Atlatl Dart 250 37 220 9
Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Arrowshafts Blunt Arrow 250 37 0 10
Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Arrowshafts Greater Blunt Arrow 250 37 0 11
Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 250 37 198 12
Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Quarrelshafts Blunt Quarrel 250 37 0 13
Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Quarrelshafts Greater Blunt Quarrel 250 37 0 14
Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Deadly Blunt Quarrel 250 37 198 15
Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Blunt Atlatl Dart 250 37 0 16
Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Blunt Atlatl Dart 250 37 0 17
Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Blunt Atlatl Dart 250 37 198 18
Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Armor Piercing Arrow 250 37 0 19
Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Greater Armor Piercing Arrow 250 37 0 20
Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Deadly Armor Piercing Arrow 250 37 220 21
Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Armor Piercing Quarrel 250 37 0 22
Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Greater Armor Piercing Quarrel 250 37 0 23
Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 250 37 220 24
Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Armor Piercing Atlatl Dart 250 37 0 25
Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Armor Piercing Atlatl Dart 250 37 0 26
Wrapped Bundle of Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Armor Piercing Atlatl Dart 250 37 220 27
Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Frog Crotch Arrow 250 37 0 28
Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Greater Frog Crotch Arrow 250 37 0 29
Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Deadly Frog Crotch Arrow 250 37 220 30
Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Frog Crotch Quarrel 250 37 0 31
Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Greater Frog Crotch Quarrel 250 37 0 32
Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 250 37 220 33
Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Frog Crotch Atlatl Dart 250 37 0 34
Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Frog Crotch Atlatl Dart 250 37 0 35
Wrapped Bundle of Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frog Crotch Atlatl Dart 250 37 220 36
Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Arrowshafts Fire Arrow 250 37 0 37
Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Arrowshafts Greater Fire Arrow 250 37 0 38
Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Arrowshafts Deadly Fire Arrow 250 37 275 39
Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Quarrelshafts Fire Quarrel 250 37 0 40
Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Quarrelshafts Greater Fire Quarrel 250 37 0 41
Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Quarrelshafts Deadly Fire Quarrel 250 37 275 42
Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Fire Atlatl Dart 250 37 0 43
Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Fire Atlatl Dart 250 37 0 44
Wrapped Bundle of Deadly Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Fire Atlatl Dart 250 37 275 45
Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Arrowshafts Lightning Arrow 250 37 0 46
Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Arrowshafts Greater Lightning Arrow 250 37 0 47
Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Arrowshafts Deadly Lightning Arrow 250 37 275 48
Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Quarrelshafts Lightning Quarrel 250 37 0 49
Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Quarrelshafts Greater Lightning Quarrel 250 37 0 50
Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Quarrelshafts Deadly Lightning Quarrel 250 37 275 51
Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Lightning Atlatl Dart 250 37 0 52
Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Lightning Atlatl Dart 250 37 0 53
Wrapped Bundle of Deadly Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Lightning Atlatl Dart 250 37 275 54
Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Arrowshafts Acid Arrow 250 37 0 55
Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Arrowshafts Greater Acid Arrow 250 37 0 56
Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 250 37 275 57
Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Quarrelshafts Acid Quarrel 250 37 0 58
Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Quarrelshafts Greater Acid Quarrel 250 37 0 59
Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Quarrelshafts Deadly Acid Quarrel 250 37 275 60
Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Acid Atlatl Dart 250 37 0 61
Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Acid Atlatl Dart 250 37 0 62
Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Acid Atlatl Dart 250 37 275 63
Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Arrowshafts Frost Arrow 250 37 0 64
Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Arrowshafts Greater Frost Arrow 250 37 0 65
Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Arrowshafts Deadly Frost Arrow 250 37 275 66
Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Quarrelshafts Frost Quarrel 250 37 0 67
Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Quarrelshafts Greater Frost Quarrel 250 37 0 68
Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frost Quarrel 250 37 275 69
Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Frost Atlatl Dart 250 37 0 70
Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Frost Atlatl Dart 250 37 0 71
Wrapped Bundle of Deadly Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frost Atlatl Dart 250 37 275 72
Bundle of Barbed Arrowheads Bundle of Arrowshafts Barbed Arrow 10 37 0 73
Bundle of Greater Barbed Arrowheads Bundle of Arrowshafts Greater Barbed Arrow 10 37 0 74
Bundle of Deadly Barbed Arrowheads Bundle of Arrowshafts Deadly Barbed Arrow 10 37 0 75
Bundle of Barbed Arrowheads Bundle of Quarrelshafts Barbed Quarrel 10 37 0 76
Bundle of Greater Barbed Arrowheads Bundle of Quarrelshafts Greater Barbed Quarrel 10 37 0 77
Bundle of Deadly Barbed Arrowheads Bundle of Quarrelshafts Deadly Barbed Quarrel 10 37 0 78
Bundle of Barbed Arrowheads Bundle of Atlatl Dart shafts Barbed Atlatl Dart 10 37 0 79
Bundle of Greater Barbed Arrowheads Bundle of Atlatl Dart shafts Greater Barbed Atlatl Dart 10 37 0 80
Bundle of Deadly Barbed Arrowheads Bundle of Atlatl Dart shafts Deadly Barbed Atlatl Dart 10 37 0 81
Bundle of Blunt Arrowheads Bundle of Arrowshafts Blunt Arrow 10 37 0 82
Bundle of Greater Blunt Arrowheads Bundle of Arrowshafts Greater Blunt Arrow 10 37 0 83
Bundle of Deadly Blunt Arrowheads Bundle of Arrowshafts Deadly Blunt Arrow 10 37 0 84
Bundle of Blunt Arrowheads Bundle of Quarrelshafts Blunt Quarrel 10 37 0 85
Bundle of Greater Blunt Arrowheads Bundle of Quarrelshafts Greater Blunt Quarrel 10 37 0 86
Bundle of Deadly Blunt Arrowheads Bundle of Quarrelshafts Deadly Blunt Quarrel 10 37 0 87
Bundle of Blunt Arrowheads Bundle of Atlatl Dart shafts Blunt Atlatl Dart 10 37 0 88
Bundle of Greater Blunt Arrowheads Bundle of Atlatl Dart shafts Greater Blunt Atlatl Dart 10 37 0 89
Bundle of Deadly Blunt Arrowheads Bundle of Atlatl Dart shafts Deadly Blunt Atlatl Dart 10 37 0 90
Bundle of Armor Piercing Arrowheads Bundle of Arrowshafts Armor Piercing Arrow 10 37 0 91
Bundle of Greater Armor Piercing Arrowheads Bundle of Arrowshafts Greater Armor Piercing Arrow 10 37 0 92
Bundle of Deadly Armor Piercing Arrowheads Bundle of Arrowshafts Deadly Armor Piercing Arrow 10 37 0 93
Bundle of Armor Piercing Arrowheads Bundle of Quarrelshafts Armor Piercing Quarrel 10 37 0 94
Bundle of Greater Armor Piercing Arrowheads Bundle of Quarrelshafts Greater Armor Piercing Quarrel 10 37 0 95
Bundle of Deadly Armor Piercing Arrowheads Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 10 37 0 96
Bundle of Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Armor Piercing Atlatl Dart 10 37 0 97
Bundle of Greater Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Greater Armor Piercing Atlatl Dart 10 37 0 98
Bundle of Deadly Armor Piercing Arrowheads Bundle of Atlatl Dart shafts Deadly Armor Piercing Atlatl Dart 10 37 0 99
Bundle of Frog Crotch Arrowheads Bundle of Arrowshafts Frog Crotch Arrow 10 37 0 100
Bundle of Greater Frog Crotch Arrowheads Bundle of Arrowshafts Greater Frog Crotch Arrow 10 37 0 101
Bundle of Deadly Frog Crotch Arrowheads Bundle of Arrowshafts Deadly Frog Crotch Arrow 10 37 0 102
Bundle of Frog Crotch Arrowheads Bundle of Quarrelshafts Frog Crotch Quarrel 10 37 0 103
Bundle of Greater Frog Crotch Arrowheads Bundle of Quarrelshafts Greater Frog Crotch Quarrel 10 37 0 104
Bundle of Deadly Frog Crotch Arrowheads Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 10 37 0 105
Bundle of Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Frog Crotch Atlatl Dart 10 37 0 106
Bundle of Greater Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Greater Frog Crotch Atlatl Dart 10 37 0 107
Bundle of Deadly Frog Crotch Arrowheads Bundle of Atlatl Dart shafts Deadly Frog Crotch Atlatl Dart 10 37 0 108
Bundle of Fire Arrowheads Bundle of Arrowshafts Fire Arrow 10 37 0 109
Bundle of Greater Fire Arrowheads Bundle of Arrowshafts Greater Fire Arrow 10 37 0 110
Bundle of Deadly Fire Arrowheads Bundle of Arrowshafts Deadly Fire Arrow 10 37 0 111
Bundle of Fire Arrowheads Bundle of Quarrelshafts Fire Quarrel 10 37 0 112
Bundle of Greater Fire Arrowheads Bundle of Quarrelshafts Greater Fire Quarrel 10 37 0 113
Bundle of Deadly Fire Arrowheads Bundle of Quarrelshafts Deadly Fire Quarrel 10 37 0 114
Bundle of Fire Arrowheads Bundle of Atlatl Dart shafts Fire Atlatl Dart 10 37 0 115
Bundle of Greater Fire Arrowheads Bundle of Atlatl Dart shafts Greater Fire Atlatl Dart 10 37 0 116
Bundle of Deadly Fire Arrowheads Bundle of Atlatl Dart shafts Deadly Fire Atlatl Dart 10 37 0 117
Bundle of Lightning Arrowheads Bundle of Arrowshafts Lightning Arrow 10 37 0 118
Bundle of Greater Lightning Arrowheads Bundle of Arrowshafts Greater Lightning Arrow 10 37 0 119
Bundle of Deadly Lightning Arrowheads Bundle of Arrowshafts Deadly Lightning Arrow 10 37 0 120
Bundle of Lightning Arrowheads Bundle of Quarrelshafts Lightning Quarrel 10 37 0 121
Bundle of Greater Lightning Arrowheads Bundle of Quarrelshafts Greater Lightning Quarrel 10 37 0 122
Bundle of Deadly Lightning Arrowheads Bundle of Quarrelshafts Deadly Lightning Quarrel 10 37 0 123
Bundle of Lightning Arrowheads Bundle of Atlatl Dart shafts Lightning Atlatl Dart 10 37 0 124
Bundle of Greater Lightning Arrowheads Bundle of Atlatl Dart shafts Greater Lightning Atlatl Dart 10 37 0 125
Bundle of Deadly Lightning Arrowheads Bundle of Atlatl Dart shafts Deadly Lightning Atlatl Dart 10 37 0 126
Bundle of Acid Arrowheads Bundle of Arrowshafts Acid Arrow 10 37 0 127
Bundle of Greater Acid Arrowheads Bundle of Arrowshafts Greater Acid Arrow 10 37 0 128
Bundle of Deadly Acid Arrowheads Bundle of Arrowshafts Deadly Acid Arrow 10 37 0 129
Bundle of Acid Arrowheads Bundle of Quarrelshafts Acid Quarrel 10 37 0 130
Bundle of Greater Acid Arrowheads Bundle of Quarrelshafts Greater Acid Quarrel 10 37 0 131
Bundle of Deadly Acid Arrowheads Bundle of Quarrelshafts Deadly Acid Quarrel 10 37 0 132
Bundle of Acid Arrowheads Bundle of Atlatl Dart shafts Acid Atlatl Dart 10 37 0 133
Bundle of Greater Acid Arrowheads Bundle of Atlatl Dart shafts Greater Acid Atlatl Dart 10 37 0 134
Bundle of Deadly Acid Arrowheads Bundle of Atlatl Dart shafts Deadly Acid Atlatl Dart 10 37 0 135
Bundle of Frost Arrowheads Bundle of Arrowshafts Frost Arrow 10 37 0 136
Bundle of Greater Frost Arrowheads Bundle of Arrowshafts Greater Frost Arrow 10 37 0 137
Bundle of Deadly Frost Arrowheads Bundle of Arrowshafts Deadly Frost Arrow 10 37 0 138
Bundle of Frost Arrowheads Bundle of Quarrelshafts Frost Quarrel 10 37 0 139
Bundle of Greater Frost Arrowheads Bundle of Quarrelshafts Greater Frost Quarrel 10 37 0 140
Bundle of Deadly Frost Arrowheads Bundle of Quarrelshafts Deadly Frost Quarrel 10 37 0 141
Bundle of Frost Arrowheads Bundle of Atlatl Dart shafts Frost Atlatl Dart 10 37 0 142
Bundle of Greater Frost Arrowheads Bundle of Atlatl Dart shafts Greater Frost Atlatl Dart 10 37 0 143
Bundle of Deadly Frost Arrowheads Bundle of Atlatl Dart shafts Deadly Frost Atlatl Dart 10 37 0 144
Cooking Pot Simple Dried Rations Simple Field Rations 25 39 0 145
Cooking Pot Elaborate Dried Rations Elaborate Field Rations 25 39 0 146
Cooking Pot Simple Dried Health Rations Simple Field Health Rations 25 39 0 147
Cooking Pot Elaborate Dried Health Rations Elaborate Field Health Rations 25 39 0 148
Cooking Pot Simple Dried Mana Rations Simple Field Mana Rations 25 39 0 149
Cooking Pot Elaborate Dried Mana Rations Elaborate Field Mana Rations 25 39 0 150
Mortar and Pestle Hot Pepper Hot Sauce 1 39 0 151
Hot Sauce Simple Dried Rations Simple Dried Health Rations 1 39 0 152
Hot Sauce Elaborate Dried Rations Elaborate Dried Health Rations 1 39 0 153
Cinnamon Simple Dried Rations Simple Dried Mana Rations 1 39 0 154
Cinnamon Elaborate Dried Rations Elaborate Dried Mana Rations 1 39 0 155
Treated Mandrake Treated Hyssop Combined Hyssop and Mandrake 1 21 0 156
Soft Bandages Combined Hyssop and Mandrake Plentiful Healing Kit 1 21 0 157
Health Infusion Potion of Healing Trade Health Elixir 0 38 0 158
Concentrated Bloodhunter Oil Wrapped Bundle of Greater Frog Crotch Arrowheads Wrapped Bundle of Deadly Frog Crotch Arrowheads 0 37 0 159
Concentrated Bloodhunter Oil Wrapped Bundle of Greater Armor Piercing Arrowheads Wrapped Bundle of Deadly Armor Piercing Arrowheads 0 37 0 160
Concentrated Bloodhunter Oil Wrapped Bundle of Greater Blunt Arrowheads Wrapped Bundle of Deadly Blunt Arrowheads 0 37 0 161
Concentrated Bloodhunter Oil Wrapped Bundle of Greater Fire Arrowheads Wrapped Bundle of Deadly Fire Arrowheads 0 37 0 162
Concentrated Bloodhunter Oil Wrapped Bundle of Greater Frost Arrowheads Wrapped Bundle of Deadly Frost Arrowheads 0 37 0 163
Concentrated Bloodhunter Oil Wrapped Bundle of Greater Acid Arrowheads Wrapped Bundle of Deadly Acid Arrowheads 0 37 0 164
Concentrated Bloodhunter Oil Wrapped Bundle of Greater Lightning Arrowheads Wrapped Bundle of Deadly Lightning Arrowheads 0 37 0 165
Concentrated Bloodseeker Oil Wrapped Bundle of Frog Crotch Arrowheads Wrapped Bundle of Greater Frog Crotch Arrowheads 0 37 0 166
Concentrated Bloodseeker Oil Wrapped Bundle of Armor Piercing Arrowheads Wrapped Bundle of Greater Armor Piercing Arrowheads 0 37 0 167
Concentrated Bloodseeker Oil Wrapped Bundle of Blunt Arrowheads Wrapped Bundle of Greater Blunt Arrowheads 0 37 0 168
Concentrated Bloodseeker Oil Wrapped Bundle of Fire Arrowheads Wrapped Bundle of Greater Fire Arrowheads 0 37 0 169
Concentrated Bloodseeker Oil Wrapped Bundle of Frost Arrowheads Wrapped Bundle of Greater Frost Arrowheads 0 37 0 170
Concentrated Bloodseeker Oil Wrapped Bundle of Acid Arrowheads Wrapped Bundle of Greater Acid Arrowheads 0 37 0 171
Concentrated Bloodseeker Oil Wrapped Bundle of Lightning Arrowheads Wrapped Bundle of Greater Lightning Arrowheads 0 37 0 172
Empty Stopped Keg Duke Raoul's Distillation Brew Keg of Duke Raoul's Distillation 0 0 0 173
Empty Stopped Keg Apothecary Zongo's Stout Brew Keg of Apothecary Zongo's Stout 0 0 0 174
Empty Stopped Keg Hunter's Stock Amber Brew Keg of Hunter's Stock Amber 0 0 0 175
Empty Bottles Keg of Apothecary Zongo's Stout Apothecary Zongo's Stout 0 0 0 176
Empty Bottles Keg of Duke Raoul's Distillation Duke Raoul's Distillation 0 0 0 177
Empty Bottles Keg of Bobo's Stout Bobo's Stout 0 0 0 178
Empty Bottles Keg of Tusker Spit Ale Tusker Spit Ale 0 0 0 179
Empty Bottles Keg of Amber Ape Amber Ape 0 0 0 180
Empty Bottles Keg of Hunter's Stock Amber Hunter's Stock Amber 0 0 0 181
Neutral Balm Strong Chorizite Oil Strong Dispel Potion 0 0 0 182
Neutral Balm Concentrated Chorizite Oil Concentrated Dispel Potion 0 0 0 183
Neutral Balm Condensed Chorizite Oil Condensed Dispel Potion 0 0 0 184
Empty Stopped Keg Tusker Spit Brew Keg of Tusker Spit Ale 0 0 0 185
Empty Stopped Keg Amber Ape Brew Keg of Amber Ape 0 0 0 186
Empty Stopped Keg Bobo's Stout Brew Keg of Bobo's Stout 0 0 0 187
Victual Oil Healing Famous Pizza Hearty Healing Famous Pizza 0 39 0 188
Victual Oil Healing Cake Hearty Healing Cake 0 39 0 189
Victual Oil Healing Carrot Cake Hearty Healing Carrot Cake 0 39 0 190
Victual Oil Healing Pizza Hearty Healing Pizza 0 39 0 191
Victual Oil Healing Applesauce Hearty Healing Applesauce 0 39 0 192
Victual Oil Healing Spiced Applesauce Hearty Healing Spiced Applesauce 0 39 0 193
Victual Oil Healing Meat Pie Hearty Healing Meat Pie 0 39 0 194
Victual Oil Healing Fish Pie Hearty Healing Fish Pie 0 39 0 195
Victual Oil Healing Chicken Pie Hearty Healing Chicken Pie 0 39 0 196
Victual Oil Healing Rabbit Pie Hearty Healing Rabbit Pie 0 39 0 197
Victual Oil Healing Mushroom Pie Hearty Healing Mushroom Pie 0 39 0 198
Victual Oil Healing Apple Pie Hearty Healing Apple Pie 0 39 0 199
Victual Oil Healing Spiced Apple Pie Hearty Healing Spiced Apple Pie 0 39 0 200
Victual Oil Healing Beef Stew Hearty Healing Beef Stew 0 39 0 201
Victual Oil Healing Fish Stew Hearty Healing Fish Stew 0 39 0 202
Victual Oil Healing Chicken Stew Hearty Healing Chicken Stew 0 39 0 203
Victual Oil Healing Rabbit Stew Hearty Healing Rabbit Stew 0 39 0 204
Victual Oil Healing Mushroom Stew Hearty Healing Mushroom Stew 0 39 0 205
Victual Oil Healing Carrot Soup Hearty Healing Carrot Soup 0 39 0 206
Victual Oil Healing Beef Noodle Hearty Healing Beef Noodle 0 39 0 207
Victual Oil Healing Fish Noodle Hearty Healing Fish Noodle 0 39 0 208
Victual Oil Healing Chicken Noodle Hearty Healing Chicken Noodle 0 39 0 209
Victual Oil Healing Rabbit Noodle Hearty Healing Rabbit Noodle 0 39 0 210
Victual Oil Healing Mushroom Noodle Hearty Healing Mushroom Noodle 0 39 0 211
Victual Oil Healing Ice Cream Hearty Healing Icecream 0 39 0 212
Victual Oil Healing Green Tea Ice Cream Hearty Healing Green Tea Ice Cream 0 39 0 213
Victual Oil Healing Holtburger Hearty Healing Holtburger 0 39 0 214
Victual Oil Healing Hot Kimchi Hearty Healing Hot Kimchi 0 39 0 215
Victual Oil Mana Hot Kimchi Hearty Mana Hot Kimchi 0 39 0 216
Victual Oil Mana Famous Pizza Hearty Mana Famous Pizza 0 39 0 217
Victual Oil Mana Green Tea Ice Cream Hearty Mana Green Tea Ice Cream 0 39 0 218
Victual Oil Mana Cake Hearty Mana Cake 0 39 0 219
Victual Oil Mana Carrot Cake Hearty Mana Carrot Cake 0 39 0 220
Victual Oil Mana Pizza Hearty Mana Pizza 0 39 0 221
Victual Oil Mana Applesauce Hearty Mana Applesauce 0 39 0 222
Victual Oil Mana Spiced Applesauce Hearty Mana Spiced Applesauce 0 39 0 223
Victual Oil Mana Meat Pie Hearty Mana Meat Pie 0 39 0 224
Victual Oil Mana Fish Pie Hearty Mana Fish Pie 0 39 0 225
Victual Oil Mana Chicken Pie Hearty Mana Chicken Pie 0 39 0 226
Victual Oil Mana Rabbit Pie Hearty Mana Rabbit Pie 0 39 0 227
Victual Oil Mana Mushroom Pie Hearty Mana Mushroom Pie 0 39 0 228
Victual Oil Mana Apple Pie Hearty Mana Apple Pie 0 39 0 229
Victual Oil Mana Spiced Apple Pie Hearty Mana Spiced Apple Pie 0 39 0 230
Victual Oil Mana Beef Stew Hearty Mana Beef Stew 0 39 0 231
Victual Oil Mana Fish Stew Hearty Mana Fish Stew 0 39 0 232
Victual Oil Mana Chicken Stew Hearty Mana Chicken Stew 0 39 0 233
Victual Oil Mana Rabbit Stew Hearty Mana Rabbit Stew 0 39 0 234
Victual Oil Mana Mushroom Stew Hearty Mana Mushroom Stew 0 39 0 235
Victual Oil Mana Carrot Soup Hearty Mana Carrot Soup 0 39 0 236
Victual Oil Mana Beef Noodle Hearty Mana Beef Noodle 0 39 0 237
Victual Oil Mana Fish Noodle Hearty Mana Fish Noodle 0 39 0 238
Victual Oil Mana Chicken Noodle Hearty Mana Chicken Noodle 0 39 0 239
Victual Oil Mana Rabbit Noodle Hearty Mana Rabbit Noodle 0 39 0 240
Victual Oil Mana Mushroom Noodle Hearty Mana Mushroom Noodle 0 39 0 241
Victual Oil Mana Ice Cream Hearty Mana Icecream 0 39 0 242
Victual Oil Mana Holtburger Hearty Mana Holtburger 0 39 0 243
Baking Pan Olthoi Chocolate Cake Batter Chocolate Olthoi Cake 0 39 0 244
Frying Pan Olthoi Egg Fried Olthoi Egg 0 39 0 245
Cooking Pot Olthoi Egg Hard Boiled Olthoi Egg 0 39 0 246
Baking Pan Olthoi Cake Batter Olthoi Cake 0 39 0 247
Baking Pan Olthoi Carrot Cake Batter Olthoi Carrot Cake 0 39 0 248
Olthoi Pumpkin Pie Filling Dough Olthoi Pumpkin Pie 0 39 0 249
Olthoi Batter Bread Olthoi Toast 0 39 0 250
Brine Olthoi Egg Pickled Olthoi Egg 0 39 0 251
Frying Pan Marinated Olthoi Egg Vesayen Style Fried Olthoi Egg 0 39 0 252
Hot Sauce Olthoi Egg Marinated Olthoi Egg 0 39 0 253
Treated Stibnite and Frankincense Crucible Powdered Onyx Gem of Greater Protection 0 38 0 254
Treated Quicksilver and Frankincense Crucible Powdered Hematite Gem of Greater Piercing Protection 0 38 0 255
Treated Verdigris and Frankincense Crucible Powdered Turquoise Gem of Greater Bludgeon Protection 0 38 0 256
Treated Cadmia and Frankincense Crucible Powdered Moonstone Gem of Greater Blade Protection 0 38 0 257
Treated Brimstone and Frankincense Crucible Powdered Malachite Gem of Greater Acid Protection 0 38 0 258
Treated Colcothar and Frankincense Crucible Powdered Quartz Gem of Greater Cold Protection 0 38 0 259
Treated Turpeth and Frankincense Crucible Powdered Carnelian Gem of Greater Fire Protection 0 38 0 260
Treated Cobalt and Frankincense Crucible Powdered Agate Gem of Greater Lightning Protection 0 38 0 261
Treated Vitriol and Frankincense Crucible Powdered Bloodstone Gem of Greater Regeneration 0 38 0 262
Treated Cinnabar and Frankincense Crucible Powdered Amber Gem of Greater Rejuvenation 0 38 0 263
Treated Gypsum and Frankincense Crucible Powdered Lapis Lazuli Gem of Greater Mana Renewal 0 38 0 264
Concentrated Health Infusion Concentrated Aqua Incanta Concentrated Health Oil 0 38 0 265
Concentrated Mana Infusion Concentrated Aqua Incanta Concentrated Mana Oil 0 38 0 266
Concentrated Victual Infusion Concentrated Aqua Incanta Concentrated Victual Oil 0 38 0 267
Eye Dropper Concentrated Health Oil Health Oil 0 38 0 268
Eye Dropper Concentrated Mana Oil Mana Oil 0 38 0 269
Eye Dropper Concentrated Victual Oil Victual Oil 0 38 0 270
Concentrated Bloodseeker Infusion Concentrated Aqua Incanta Concentrated Bloodseeker Oil 0 38 0 271
Concentrated Bloodhunter Infusion Concentrated Aqua Incanta Concentrated Bloodhunter Oil 0 38 0 272
Concentrated Fire Infusion Concentrated Aqua Incanta Concentrated Fire Oil 0 38 0 273
Concentrated Frost Infusion Concentrated Aqua Incanta Concentrated Frost Oil 0 38 0 274
Concentrated Acid Infusion Concentrated Aqua Incanta Concentrated Acid Oil 0 38 0 275
Concentrated Lightning Infusion Concentrated Aqua Incanta Concentrated Lightning Oil 0 38 0 276
Bloodhunter Infusion Aqua Incanta Bloodhunter Oil 0 38 0 277
Bloodseeker Infusion Aqua Incanta Bloodseeker Oil 0 38 0 278
Lightning Infusion Aqua Incanta Lightning Oil 0 38 0 279
Fire Infusion Aqua Incanta Fire Oil 0 38 0 280
Acid Infusion Aqua Incanta Acid Oil 0 38 0 281
Frost Infusion Aqua Incanta Frost Oil 0 38 0 282
Wrapped Bundle of Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 0 37 0 283
Wrapped Bundle of Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 0 37 0 284
Bundle of Deadly Arrowheads Bundle of Arrowshafts Deadly Arrow 0 37 0 285
Wrapped Bundle of Greater Blunt Arrowheads Bundle of Arrowshafts Greater Blunt Arrow 0 37 0 286
Wrapped Bundle of Greater Frog Crotch Arrowheads Bundle of Arrowshafts Greater Frog Crotch Arrow 0 37 0 287
Baking Pan Dough Bread 0 39 0 288
Carving Knife Cabbage Coleslaw 0 39 0 289
Frying Pan Dough Flat Bread 0 39 0 290
Frying Pan Brimstone-cap Mushroom Fried Mushroom 0 39 0 291
Brine Egg Pickled Egg 0 39 0 292
Brine Fish Filet Pickled Fish 0 39 0 293
Rich Carrot Stock Cheese Carol's Carrot Soup 0 39 0 294
Cubed Carrot Cake Milk Carrot Cake Soup 0 39 0 295
Cooking Pot Spiced Pumpkin Pumpkin Soup 0 39 0 296
Uncooked Rice Grapes Stuffed Grape Leaf 0 39 0 297
Baking Pan Cheese Filled Mushroom Stuffed Mushroom 0 0 0 298
Uncooked Rice Fish Filet Sushi 0 39 0 299
Rat Tail Ground Rabbit Rabbit Sausage 0 39 0 300
Rat Tail Ground Meat Sausage 0 39 0 301
Hot Sauce Sausage Spicy Sausage 0 39 0 302
Metal Press Apple Apple Juice 0 39 0 303
Bitter Milk Honey Chocolate Milk 0 39 0 304
Crushed Ice Milk Cold Milk 0 39 0 305
Egg Spiced Milk Eggnog 0 39 0 306
Sweetened Hot Milk Cocoa Powder Hot Chocolate 0 39 0 307
Crushed Ice Mocha Iced Mocha 0 39 0 308
Mocha Base Milk Mocha 0 39 0 309
Peppermint Stick Hot Chocolate Peppermint Hot Chocolate 0 0 0 310
Crushed Ice Rich Mocha Rich Iced Mocha 0 39 0 311
Cinnamon Mocha Rich Mocha 0 39 0 312
Slice of Bread Cheese Cheese Sandwich 0 39 0 313
Slice of Bread Chicken Chicken Sandwich 0 39 0 314
Ravener Gut Ground Meat Drudge Gut Sausage 0 39 0 315
Slice of Bread Egg Egg Sandwich 0 39 0 316
Slice of Bread Fish Fish Sandwich 0 39 0 317
Frying Pan Cheese Sandwich Grilled Cheese Sandwich 0 39 0 318
Ground Meat Bread Holtburger 0 39 0 319
Dough Apple Apple Pie 0 39 0 320
Heavy Grinder Apple Applesauce 0 39 0 321
Baking Pan Cake Batter Cake 0 39 0 322
Monougat Apple Candied Apple 0 39 0 323
Baking Pan Carrot Cake Batter Carrot Cake 0 39 0 324
Baking Pan Chocolate Cake Batter Chocolate Cake 0 39 0 325
Baking Pan Chocolate Cookie Dough Chocolate Cookie 0 39 0 326
Chocolate Liquor Ice Cream Chocolate Ice Cream 0 39 0 327
Baking Pan Cookie Dough Cookie 0 39 0 328
Cocoa Mixture Honey Bar Dark Chocolate 0 39 0 329
Monougat Bar Dark Chocolate Dark Chocolate Candy Bar 0 39 0 330
Baking Pan Fruitcake Batter Fruitcake 0 39 0 331
Baking Pan Ginger Dough Ginger Bread 0 39 0 332
Frozen Green Tea Honey Green Tea Ice Cream 0 39 0 333
Frozen Cream Honey Ice Cream 0 39 0 334
Milky Cocoa Mixture Honey Bar Milk Chocolate 0 39 0 335
Monougat Bar Milk Chocolate Milk Chocolate Candy Bar 0 39 0 336
Baking Pan Peppermint Chocolate Cookie Dough Peppermint Chocolate Cookie 0 39 0 337
Baking Pan Peppermint Cookie Dough Peppermint Cookie 0 39 0 338
Peppermint Stick Ice Cream Peppermint Ice Cream 0 39 0 339
Monougat Peppermint Stick Peppermint Monougat Chew 0 39 0 340
Pumpkin Pie Filling Dough Pumpkin Pie 0 39 0 341
Dough Spiced Apple Filling Spiced Apple Pie 0 39 0 342
Cinnamon Applesauce Spiced Applesauce 0 39 0 343
Raw Noodles Cheese Cragstone Farms Mac and Cheese 0 39 0 344
Raw Egg Noodles Ground Beef Cragstonanoff 0 39 0 345
Rice Dough Chicken Chicken Dumpling 0 39 0 346
Rice Dough Fish Fish Dumpling 0 39 0 347
Frying Pan Chicken Piece Fried Chicken 0 39 0 348
Frying Pan Egg Fried Egg 0 39 0 349
Frying Pan Fish Filet Fried Fish 0 39 0 350
Frying Pan Rabbit Piece Fried Rabbit 0 39 0 351
Frying Pan Steak Fried Steak 0 39 0 352
Skewer Steak Beef Kebob 0 39 0 353
Skewer Chicken Piece Chicken Kebob 0 39 0 354
Skewer Fish Filet Fish Kebob 0 39 0 355
Skewer Brimstone-cap Mushroom Mushroom Kebob 0 39 0 356
Skewer Rabbit Piece Rabbit Kebob 0 39 0 357
Brine Cabbage Kimchi 0 39 0 358
Hot Sauce Kimchi Hot Kimchi 0 39 0 359
Fire Oil Hot Kimchi Flaming Kimchi 0 39 0 360
Raw Noodles Steak Beef Noodle 0 39 0 361
Raw Noodles Chicken Piece Chicken Noodle 0 39 0 362
Raw Noodles Fish Filet Fish Noodle 0 39 0 363
Raw Noodles Brimstone-cap Mushroom Mushroom Noodle 0 39 0 364
Raw Noodles Rabbit Piece Rabbit Noodle 0 39 0 365
Dough Chicken Piece Chicken Pie 0 39 0 366
Dough Fish Filet Fish Pie 0 39 0 367
Dough Steak Meat Pie 0 39 0 368
Dough Brimstone-cap Mushroom Mushroom Pie 0 39 0 369
Dough Rabbit Piece Rabbit Pie 0 39 0 370
Cooking Pot Uncooked Rice Bowl of Rice 0 39 0 371
Uncooked Rice Steak Beef Rice 0 39 0 372
Uncooked Rice Chicken Piece Chicken Rice 0 39 0 373
Uncooked Rice Brimstone-cap Mushroom Mushroom Rice 0 39 0 374
Uncooked Rice Rabbit Piece Rabbit Rice 0 39 0 375
Cooking Pot Steak Beef Stew 0 39 0 376
Cooking Pot Chicken Piece Chicken Stew 0 39 0 377
Cooking Pot Fish Filet Fish Stew 0 39 0 378
Cooking Pot Brimstone-cap Mushroom Mushroom Stew 0 39 0 379
Cooking Pot Rabbit Piece Rabbit Stew 0 39 0 380
Batter Bread Viamont Toast 0 39 0 381
Oregano Pizza Famous Pizza 0 39 0 382
Dough Cheese Pizza 0 39 0 383
Health Oil Cake Healing Cake 0 39 0 384
Health Oil Carrot Cake Healing Carrot Cake 0 39 0 385
Health Oil Pizza Healing Pizza 0 39 0 386
Health Oil Famous Pizza Healing Famous Pizza 0 39 0 387
Health Oil Applesauce Healing Applesauce 0 39 0 388
Health Oil Spiced Applesauce Healing Spiced Applesauce 0 39 0 389
Health Oil Meat Pie Healing Meat Pie 0 39 0 390
Health Oil Fish Pie Healing Fish Pie 0 39 0 391
Health Oil Chicken Pie Healing Chicken Pie 0 39 0 392
Health Oil Rabbit Pie Healing Rabbit Pie 0 39 0 393
Health Oil Mushroom Pie Healing Mushroom Pie 0 39 0 394
Health Oil Apple Pie Healing Apple Pie 0 39 0 395
Health Oil Spiced Apple Pie Healing Spiced Apple Pie 0 39 0 396
Health Oil Beef Stew Healing Beef Stew 0 39 0 397
Health Oil Fish Stew Healing Fish Stew 0 39 0 398
Health Oil Chicken Stew Healing Chicken Stew 0 39 0 399
Health Oil Rabbit Stew Healing Rabbit Stew 0 39 0 400
Health Oil Mushroom Stew Healing Mushroom Stew 0 39 0 401
Health Oil Carrot Soup Healing Carrot Soup 0 39 0 402
Health Oil Beef Noodle Healing Beef Noodle 0 39 0 403
Health Oil Fish Noodle Healing Fish Noodle 0 39 0 404
Health Oil Chicken Noodle Healing Chicken Noodle 0 39 0 405
Health Oil Rabbit Noodle Healing Rabbit Noodle 0 39 0 406
Health Oil Mushroom Noodle Healing Mushroom Noodle 0 39 0 407
Health Oil Ice Cream Healing Icecream 0 39 0 408
Health Oil Green Tea Ice Cream Healing Green Tea Ice Cream 0 39 0 409
Health Oil Holtburger Healing Holtburger 0 39 0 410
Health Oil Hot Kimchi Healing Hot Kimchi 0 39 0 411
Victual Oil Cake Hearty Cake 0 39 0 412
Victual Oil Carrot Cake Hearty Carrot Cake 0 39 0 413
Victual Oil Pizza Hearty Pizza 0 39 0 414
Victual Oil Famous Pizza Hearty Famous Pizza 0 39 0 415
Victual Oil Applesauce Hearty Applesauce 0 39 0 416
Victual Oil Spiced Applesauce Hearty Spiced Applesauce 0 39 0 417
Victual Oil Meat Pie Hearty Meat Pie 0 39 0 418
Victual Oil Fish Pie Hearty Fish Pie 0 39 0 419
Victual Oil Chicken Pie Hearty Chicken Pie 0 39 0 420
Victual Oil Rabbit Pie Hearty Rabbit Pie 0 39 0 421
Victual Oil Mushroom Pie Hearty Mushroom Pie 0 39 0 422
Victual Oil Apple Pie Hearty Apple Pie 0 39 0 423
Victual Oil Spiced Apple Pie Hearty Spiced Apple Pie 0 39 0 424
Victual Oil Beef Stew Hearty Beef Stew 0 39 0 425
Victual Oil Fish Stew Hearty Fish Stew 0 39 0 426
Victual Oil Chicken Stew Hearty Chicken Stew 0 39 0 427
Victual Oil Rabbit Stew Hearty Rabbit Stew 0 39 0 428
Victual Oil Mushroom Stew Hearty Mushroom Stew 0 39 0 429
Victual Oil Carrot Soup Hearty Carrot Soup 0 39 0 430
Victual Oil Beef Noodle Hearty Beef Noodle 0 39 0 431
Victual Oil Fish Noodle Hearty Fish Noodle 0 39 0 432
Victual Oil Chicken Noodle Hearty Chicken Noodle 0 39 0 433
Victual Oil Rabbit Noodle Hearty Rabbit Noodle 0 39 0 434
Victual Oil Mushroom Noodle Hearty Mushroom Noodle 0 39 0 435
Victual Oil Ice Cream Hearty Icecream 0 39 0 436
Victual Oil Green Tea Ice Cream Hearty Green Tea Ice Cream 0 39 0 437
Victual Oil Holtburger Hearty Holtburger 0 39 0 438
Victual Oil Hot Kimchi Hearty Hot Kimchi 0 39 0 439
Mana Oil Cake Mana Cake 0 39 0 440
Mana Oil Carrot Cake Mana Carrot Cake 0 39 0 441
Mana Oil Pizza Mana Pizza 0 39 0 442
Mana Oil Famous Pizza Mana Famous Pizza 0 39 0 443
Mana Oil Applesauce Mana Applesauce 0 39 0 444
Mana Oil Spiced Applesauce Mana Spiced Applesauce 0 39 0 445
Mana Oil Meat Pie Mana Meat Pie 0 39 0 446
Mana Oil Fish Pie Mana Fish Pie 0 39 0 447
Mana Oil Chicken Pie Mana Chicken Pie 0 39 0 448
Mana Oil Rabbit Pie Mana Rabbit Pie 0 39 0 449
Mana Oil Mushroom Pie Mana Mushroom Pie 0 39 0 450
Mana Oil Apple Pie Mana Apple Pie 0 39 0 451
Mana Oil Spiced Apple Pie Mana Spiced Apple Pie 0 39 0 452
Mana Oil Beef Stew Mana Beef Stew 0 39 0 453
Mana Oil Fish Stew Mana Fish Stew 0 39 0 454
Mana Oil Chicken Stew Mana Chicken Stew 0 39 0 455
Mana Oil Rabbit Stew Mana Rabbit Stew 0 39 0 456
Mana Oil Mushroom Stew Mana Mushroom Stew 0 39 0 457
Mana Oil Carrot Soup Mana Carrot Soup 0 39 0 458
Mana Oil Beef Noodle Mana Beef Noodle 0 39 0 459
Mana Oil Fish Noodle Mana Fish Noodle 0 39 0 460
Mana Oil Chicken Noodle Mana Chicken Noodle 0 39 0 461
Mana Oil Rabbit Noodle Mana Rabbit Noodle 0 39 0 462
Mana Oil Mushroom Noodle Mana Mushroom Noodle 0 39 0 463
Mana Oil Ice Cream Mana Icecream 0 39 0 464
Mana Oil Green Tea Ice Cream Mana Green Tea Ice Cream 0 39 0 465
Mana Oil Holtburger Mana Holtburger 0 39 0 466
Mana Oil Hot Kimchi Mana Hot Kimchi 0 39 0 467
Bloodhunter Oil Bundle of Greater Acid Arrowheads Bundle of Deadly Acid Arrowheads 0 37 0 468
Bloodhunter Oil Bundle of Greater Arrowheads Bundle of Deadly Arrowheads 0 37 0 469
Bloodhunter Oil Bundle of Greater Blunt Arrowheads Bundle of Deadly Blunt Arrowheads 0 37 0 470
Bloodhunter Oil Bundle of Greater Frog Crotch Arrowheads Bundle of Deadly Frog Crotch Arrowheads 0 37 0 471
Concentrated Fire Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Fire Arrowheads 0 37 0 472
Concentrated Frost Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Frost Arrowheads 0 37 0 473
Concentrated Acid Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Acid Arrowheads 0 37 0 474
Concentrated Lightning Oil Wrapped Bundle of Arrowheads Wrapped Bundle of Lightning Arrowheads 0 37 0 475
Lightning Oil Bundle of Arrowheads Bundle of Lightning Arrowheads 0 37 0 476
Fire Oil Bundle of Arrowheads Bundle of Fire Arrowheads 0 37 0 477
Frost Oil Bundle of Arrowheads Bundle of Frost Arrowheads 0 37 0 478
Acid Oil Bundle of Arrowheads Bundle of Acid Arrowheads 0 37 0 479
Bloodseeker Oil Bundle of Blunt Arrowheads Bundle of Greater Blunt Arrowheads 0 37 0 480
Bloodseeker Oil Bundle of Frog Crotch Arrowheads Bundle of Greater Frog Crotch Arrowheads 0 37 0 481
Bloodseeker Oil Bundle of Arrowheads Bundle of Greater Arrowheads 0 37 0 482
Bloodseeker Oil Bundle of Fire Arrowheads Bundle of Greater Fire Arrowheads 0 37 0 483
Bloodseeker Oil Bundle of Acid Arrowheads Bundle of Greater Acid Arrowheads 0 37 0 484
Bloodseeker Oil Bundle of Frost Arrowheads Bundle of Greater Frost Arrowheads 0 37 0 485
Bloodseeker Oil Bundle of Lightning Arrowheads Bundle of Greater Lightning Arrowheads 0 37 0 486
Eye Dropper Concentrated Health Infusion Health Infusion 0 38 0 487
Eye Dropper Concentrated Mana Infusion Mana Infusion 0 38 0 488
Eye Dropper Concentrated Victual Infusion Victual Infusion 0 38 0 489
Alembic Quicksilver Bloodseeker Infusion 0 38 0 490
Alembic Stibnite Bloodhunter Infusion 0 38 0 491
Alembic Cobalt Lightning Infusion 0 38 0 492
Alembic Turpeth Fire Infusion 0 38 0 493
Alembic Colcothar Frost Infusion 0 38 0 494
Alembic Brimstone Acid Infusion 0 38 0 495
Alembic Vitriol Pea Concentrated Health Infusion 0 38 0 496
Alembic Gypsum Pea Concentrated Mana Infusion 0 38 0 497
Alembic Realgar Pea Concentrated Victual Infusion 0 38 0 498
Alembic Quicksilver Pea Concentrated Bloodseeker Infusion 0 38 0 499
Alembic Stibnite Pea Concentrated Bloodhunter Infusion 0 38 0 500
Alembic Turpeth Pea Concentrated Fire Infusion 0 38 0 501
Alembic Colcothar Pea Concentrated Frost Infusion 0 38 0 502
Alembic Brimstone Pea Concentrated Acid Infusion 0 38 0 503
Alembic Cobalt Pea Concentrated Lightning Infusion 0 38 0 504
Crucible Stibnite Crucible with Stibnite Potion 0 38 0 505
Crucible with Stibnite Potion Frankincense Stibnite and Frankincense Crucible 0 38 0 506
Aqua Vitae Stibnite and Frankincense Crucible Treated Stibnite and Frankincense Crucible 0 38 0 507
Crucible Quicksilver Crucible with Quicksilver Potion 0 38 0 508
Crucible with Quicksilver Potion Frankincense Quicksilver and Frankincense Crucible 0 38 0 509
Aqua Vitae Quicksilver and Frankincense Crucible Treated Quicksilver and Frankincense Crucible 0 38 0 510
Crucible Verdigris Crucible with Verdigris Potion 0 38 0 511
Crucible with Verdigris Potion Frankincense Verdigris and Frankincense Crucible 0 38 0 512
Aqua Vitae Verdigris and Frankincense Crucible Treated Verdigris and Frankincense Crucible 0 38 0 513
Crucible Cadmia Crucible with Cadmia Potion 0 38 0 514
Crucible with Cadmia Potion Frankincense Cadmia and Frankincense Crucible 0 38 0 515
Aqua Vitae Cadmia and Frankincense Crucible Treated Cadmia and Frankincense Crucible 0 38 0 516
Crucible Brimstone Crucible with Brimstone Potion 0 38 0 517
Crucible with Brimstone Potion Frankincense Brimstone and Frankincense Crucible 0 38 0 518
Aqua Vitae Brimstone and Frankincense Crucible Treated Brimstone and Frankincense Crucible 0 38 0 519
Crucible Colcothar Crucible with Colcothar Potion 0 38 0 520
Crucible with Colcothar Potion Frankincense Colcothar and Frankincense Crucible 0 38 0 521
Aqua Vitae Colcothar and Frankincense Crucible Treated Colcothar and Frankincense Crucible 0 38 0 522
Crucible Turpeth Crucible with Turpeth Potion 0 38 0 523
Crucible with Turpeth Potion Frankincense Turpeth and Frankincense Crucible 0 38 0 524
Aqua Vitae Turpeth and Frankincense Crucible Treated Turpeth and Frankincense Crucible 0 38 0 525
Crucible Cobalt Crucible with Cobalt Potion 0 38 0 526
Crucible with Cobalt Potion Frankincense Cobalt and Frankincense Crucible 0 38 0 527
Aqua Vitae Cobalt and Frankincense Crucible Treated Cobalt and Frankincense Crucible 0 38 0 528
Crucible Vitriol Crucible with Vitriol Potion 0 38 0 529
Crucible with Vitriol Potion Frankincense Vitriol and Frankincense Crucible 0 38 0 530
Aqua Vitae Vitriol and Frankincense Crucible Treated Vitriol and Frankincense Crucible 0 38 0 531
Crucible Cinnabar Crucible with Cinnabar Potion 0 38 0 532
Crucible with Cinnabar Potion Frankincense Cinnabar and Frankincense Crucible 0 38 0 533
Aqua Vitae Cinnabar and Frankincense Crucible Treated Cinnabar and Frankincense Crucible 0 38 0 534
Crucible Gypsum Crucible with Gypsum Potion 0 38 0 535
Crucible with Gypsum Potion Frankincense Gypsum and Frankincense Crucible 0 38 0 536
Aqua Vitae Gypsum and Frankincense Crucible Treated Gypsum and Frankincense Crucible 0 38 0 537
Ground Chorizite Vitriol Chorizite 0 0 0 538
Alembic Chorizite Chorizite Oil 0 0 0 539
Chorizite Oil Chorizite Oil Strong Chorizite Oil 0 0 0 540
Chorizite Oil Strong Chorizite Oil Concentrated Chorizite Oil 0 0 0 541
Chorizite Oil Concentrated Chorizite Oil Condensed Chorizite Oil 0 0 0 542
Cocoa Mixture Milk Milky Cocoa Mixture 0 39 0 543
Mortar and Pestle Cinnamon Bark Cinnamon 0 0 0 544
Heavy Grinder Ginger Ground Ginger 0 0 0 545
Mortar and Pestle Hot Pepper Hot Sauce 0 0 0 546
Heavy Grinder Nutmeg Ground Nutmeg 0 0 0 547
Flour Water Dough 0 0 0 548
Carving Knife Fish Fish Filet 0 39 0 549
Carving Knife Brimstone-cap Mushroom Stemless Mushroom 0 39 0 550
Rennet Milk Cheese 0 39 0 551
Stemless Mushroom Cheese Cheese Filled Mushroom 0 39 0 552
Dough Egg Batter 0 39 0 553
Baking Pan Brown Beans Roasted Beans 0 39 0 554
Heavy Grinder Roasted Beans Chocolate Liquor 0 39 0 555
Metal Press Chocolate Liquor Cocoa Powder 0 39 0 556
Cocoa Powder Milk Bitter Milk 0 39 0 557
Heavy Grinder Magic Iceball Crushed Ice 0 39 0 558
Ground Nutmeg Milk Spiced Milk 0 39 0 559
Cooking Pot Milk Hot Milk 0 39 0 560
Hot Milk Honey Sweetened Hot Milk 0 39 0 561
Cocoa Powder Coffee Mocha Base 0 39 0 562
Whittling Knife Strange Stick Cinnamon Bark 0 39 0 563
Carving Knife Bread Slice of Bread 0 39 0 564
Carving Knife Side of Beef Steak 0 39 0 565
Heavy Grinder Steak Ground Meat 0 39 0 566
Heavy Grinder Rabbit Piece Ground Rabbit 0 39 0 567
Batter Flour Cake Batter 0 39 0 568
Cake Batter Carrot Carrot Cake Batter 0 39 0 569
Cake Batter Cocoa Powder Chocolate Cake Batter 0 39 0 570
Dough Honey Cookie Dough 0 39 0 571
Cocoa Powder Cookie Dough Chocolate Cookie Dough 0 39 0 572
Chocolate Liquor Cocoa Powder Cocoa Mixture 0 39 0 573
Cinnamon Mocha Rich Mocha 0 39 0 574
Cinnamon Brown Lump Spiced Lump 0 39 0 575
Flour Spiced Lump Spiced Lumpy Flour 0 39 0 576
Spiced Lumpy Flour Egg Rich Lumpy Flour 0 39 0 577
Rich Lumpy Flour Red Wine Fruitcake Batter 0 39 0 578
Ground Ginger Dough Ginger Dough 0 39 0 579
Frozen Cream Green Tea Frozen Green Tea 0 39 0 580
Magic Iceball Milk Frozen Cream 0 39 0 581
Peppermint Stick Chocolate Cookie Dough Peppermint Chocolate Cookie Dough 0 39 0 582
Peppermint Stick Cookie Dough Peppermint Cookie Dough 0 39 0 583
Baking Pan Pumpkin Cooked Pumpkin 0 39 0 584
Cooked Pumpkin Milk Liquid Pumpkin 0 39 0 585
Liquid Pumpkin Honey Sweetened Pumpkin 0 39 0 586
Sweetened Pumpkin Cinnamon Spiced Pumpkin 0 39 0 587
Spiced Pumpkin Egg Pumpkin Pie Filling 0 39 0 588
Cinnamon Apple Spiced Apple Filling 0 39 0 589
Carving Knife Chicken Chicken Pieces 0 39 0 590
Carving Knife Rabbit Carcass Rabbit Pieces 0 39 0 591
Noodle Cutter Dough Raw Noodles 0 39 0 592
Dough Olthoi Egg Olthoi Batter 0 39 0 593
Flour Olthoi Batter Olthoi Cake Batter 0 39 0 594
Olthoi Cake Batter Carrot Olthoi Carrot Cake Batter 0 39 0 595
Olthoi Cake Batter Chocolate Powder Olthoi Chocolate Cake Batter 0 39 0 596
Spiced Pumpkin Filling Olthoi Egg Olthoi Pumpkin Pie Filling 0 39 0 597
Cooking Pot Carrot Carrot Stock 0 39 0 598
Carrot Stock Milk Rich Carrot Stock 0 39 0 599
Mortar and Pestle Uncooked Rice Rice Flour 0 39 0 600
Rice Flour Water Rice Dough 0 39 0 601
Carving Knife Carrot Cake Cubed Carrot Cake 0 39 0 602
Noodle Cutter Batter Raw Egg Noodles 0 39 0 603
Baking Pan Plain Barley Roasted Barley 0 39 0 604
Brew Kettle Water Full Brew Kettle 0 39 0 605
Roasted Barley Full Brew Kettle Dark Wort 0 39 0 606
Ultra Green Hops Dark Wort Aromatic Dark Wort 0 39 0 607
Dried Yeast Aromatic Dark Wort Glorious Dark Brew 0 39 0 608
Amber Barley Full Brew Kettle Amber Wort 0 39 0 609
Ultra Green Hops Amber Wort Aromatic Amber Wort 0 39 0 610
Dried Yeast Aromatic Amber Wort Glorious Amber Brew 0 39 0 611
Plain Barley Full Brew Kettle Sweet Wort 0 39 0 612
Ultra Green Hops Sweet Wort Aromatic Finished Wort 0 39 0 613
Dried Yeast Aromatic Finished Wort Glorious Fermented Brew 0 39 0 614
Moarsmuck Glorious Dark Brew Apothecary Zongo's Stout Brew 0 39 0 615
Moarsmuck Glorious Amber Brew Hunter's Stock Amber Brew 0 39 0 616
Moarsmuck Glorious Fermented Brew Duke Raoul's Distillation Brew 0 39 0 617
Tusker Spit Glorious Dark Brew Bobo's Stout Brew 0 39 0 618
Tusker Spit Glorious Amber Brew Amber Ape Brew 0 39 0 619
Tusker Spit Glorious Fermented Brew Tusker Spit Brew 0 39 0 620
Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Arrowshafts Raider Lightning Arrow 250 37 0 621
Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Quarrelshafts Raider Lightning Bolt 250 37 0 622
Wrapped Bundle of Raider Lightning Arrowheads Wrapped Bundle of Atlatl Dartshafts Raider Lightning Atlatl Dart 250 37 0 623
Wrapped Bundle of Arrowheads Wrapped Bundle of Arrowshafts Arrow 250 37 0 624
Wrapped Bundle of Arrowheads Wrapped Bundle of Atlatl Dartshafts Atlatl Dart 250 37 0 625
Wrapped Bundle of Arrowheads Wrapped Bundle of Quarrelshafts Quarrel 250 37 0 626
Carving Knife Cured Mushroom Stalk Tiriun Stalk Jerky 10 39 100 627
Hot Sauce Tiriun Mushroom Stalk Cured Mushroom Stalk 1 39 100 628
Cooking Pot Tiriun Mushroom Spores Roasted Tiriun Spores 1 39 100 629
Mortar and Pestle Roasted Tiriun Spores Tiriun Spore Powder 10 39 100 630
Skewer Tiriun Mushroom Cap Roasted Tiriun Cap 1 39 100 631
Carving Knife Roasted Tiriun Cap Tiriun Cap Wafer 10 39 100 632
Splitting Tool Lead Pea Lead Scarab 20 33 0 633
Splitting Tool Iron Pea Iron Scarab 20 33 0 634
Splitting Tool Copper Pea Copper Scarab 20 33 0 635
Splitting Tool Silver Pea Silver Scarab 20 33 0 636
Splitting Tool Gold Pea Gold Scarab 20 33 0 637
Splitting Tool Pyreal Pea Pyreal Scarab 20 33 0 638
Splitting Tool Amaranth Pea Amaranth 50 33 0 639
Splitting Tool Bistort Pea Bistort 50 33 0 640
Splitting Tool Comfrey Pea Comfrey 50 33 0 641
Splitting Tool Damiana Pea Damiana 50 33 0 642
Splitting Tool Dragonsblood Pea Dragonsblood 50 33 0 643
Splitting Tool Eyebright Pea Eyebright 50 33 0 644
Splitting Tool Frankincense Pea Frankincense 50 33 0 645
Splitting Tool Ginseng Pea Ginseng 50 33 0 646
Splitting Tool Hawthorn Pea Hawthorn 50 33 0 647
Splitting Tool Henbane Pea Henbane 50 33 0 648
Splitting Tool Hyssop Pea Hyssop 50 33 0 649
Splitting Tool Mandrake Pea Mandrake 50 33 0 650
Splitting Tool Mugwort Pea Mugwort 50 33 0 651
Splitting Tool Myrrh Pea Myrrh 50 33 0 652
Splitting Tool Saffron Pea Saffron 50 33 0 653
Splitting Tool Vervain Pea Vervain 50 33 0 654
Splitting Tool Wormwood Pea Wormwood 50 33 0 655
Splitting Tool Yarrow Pea Yarrow 50 33 0 656
Splitting Tool Powdered Agate Pea Powdered Agate 50 33 0 657
Splitting Tool Powdered Amber Pea Powdered Amber 50 33 0 658
Splitting Tool Powdered Azurite Pea Powdered Azurite 50 33 0 659
Splitting Tool Powdered Bloodstone Pea Powdered Bloodstone 50 33 0 660
Splitting Tool Powdered Carnelian Pea Powdered Carnelian 50 33 0 661
Splitting Tool Powdered Hematite Pea Powdered Hematite 50 33 0 662
Splitting Tool Powdered Lapis Lazuli Pea Powdered Lapis Lazuli 50 33 0 663
Splitting Tool Powdered Malachite Pea Powdered Malachite 50 33 0 664
Splitting Tool Powdered Moonstone Pea Powdered Moonstone 50 33 0 665
Splitting Tool Powdered Onyx Pea Powdered Onyx 50 33 0 666
Splitting Tool Powdered Quartz Pea Powdered Quartz 50 33 0 667
Splitting Tool Powdered Turquoise Pea Powdered Turquoise 50 33 0 668
Splitting Tool Brimstone Pea Brimstone 50 33 0 669
Splitting Tool Cadmia Pea Cadmia 50 33 0 670
Splitting Tool Cinnabar Pea Cinnabar 50 33 0 671
Splitting Tool Cobalt Pea Cobalt 50 33 0 672
Splitting Tool Colcothar Pea Colcothar 50 33 0 673
Splitting Tool Gypsum Pea Gypsum 50 33 0 674
Splitting Tool Quicksilver Pea Quicksilver 50 33 0 675
Splitting Tool Realgar Pea Realgar 50 33 0 676
Splitting Tool Stibnite Pea Stibnite 50 33 0 677
Splitting Tool Turpeth Pea Turpeth 50 33 0 678
Splitting Tool Verdigris Pea Verdigris 50 33 0 679
Splitting Tool Vitriol Pea Vitriol 50 33 0 680
Splitting Tool Poplar Pea Poplar Talisman 20 33 0 681
Splitting Tool Blackthorn Pea Blackthorn Talisman 20 33 0 682
Splitting Tool Yew Pea Yew Talisman 20 33 0 683
Splitting Tool Hemlock Pea Hemlock Talisman 20 33 0 684
Splitting Tool Alder Pea Alder Talisman 20 33 0 685
Splitting Tool Ebony Pea Ebony Talisman 20 33 0 686
Splitting Tool Birch Pea Birch Talisman 20 33 0 687
Splitting Tool Ashwood Pea Ashwood Talisman 20 33 0 688
Splitting Tool Elder Pea Elder Talisman 20 33 0 689
Splitting Tool Rowan Pea Rowan Talisman 20 33 0 690
Splitting Tool Willow Pea Willow Talisman 20 33 0 691
Splitting Tool Cedar Pea Cedar Talisman 20 33 0 692
Splitting Tool Oak Pea Oak Talisman 20 33 0 693
Splitting Tool Hazel Pea Hazel Talisman 20 33 0 694
Splitting Tool Red Pea Red Taper 50 33 0 695
Splitting Tool Pink Pea Pink Taper 50 33 0 696
Splitting Tool Orange Pea Orange Taper 50 33 0 697
Splitting Tool Yellow Pea Yellow Taper 50 33 0 698
Splitting Tool Green Pea Green Taper 50 33 0 699
Splitting Tool Turquoise Pea Turquoise Taper 50 33 0 700
Splitting Tool Blue Pea Blue Taper 50 33 0 701
Splitting Tool Indigo Pea Indigo Taper 50 33 0 702
Splitting Tool Violet Pea Violet Taper 50 33 0 703
Splitting Tool Brown Pea Brown Taper 50 33 0 704
Splitting Tool White Pea White Taper 50 33 0 705
Splitting Tool Grey Pea Grey Taper 50 33 0 706
Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Blunt Arrow 250 37 0 707
Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Blunt Quarrel 250 37 0 708
Wrapped Bundle of Greater Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Blunt Atlatl Dart 250 37 0 709
Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Arrowshafts Olthoi Acid Arrow 2500 37 0 710
Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Quarrelshafts Olthoi Acid Bolt 2500 37 0 711
Wrapped Bundle of Olthoi Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Olthoi Acid Atlatl Dart 2500 37 0 712
Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Arrowshafts Gear Blade Slashing Arrow 250 37 0 713
Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Quarrelshafts Gear Blade Slashing Bolt 250 37 0 714
Wrapped Bundle of Gear Blade Slashing Arrowheads Wrapped Bundle of Atlatl Dartshafts Gear Blade Slashing Atlatl Dart 250 37 0 715
Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Armor Piercing Atlatl Dart 500 37 0 716
Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Armor Piercing Quarrel 500 37 0 717
Wrapped Bundle of Greater Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Armor Piercing Arrow 500 37 0 718
Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Atlatl Dartshafts Burning Sands Atlatl Dart 500 37 0 719
Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Quarrelshafts Burning Sands Bolt 500 37 0 720
Wrapped Bundle of Burning Sands Arrowheads Wrapped Bundle of Arrowshafts Burning Sands Arrow 500 37 0 721
Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Greater Deadly Frog Crotch Arrow 500 37 0 722
Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Greater Deadly Frog Crotch Quarrel 500 37 0 723
Wrapped Bundle of Greater Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Deadly Frog Crotch Atlatl Dart 500 37 0 724
Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Prismatic Atlatl Dart 500 37 0 725
Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Deadly Prismatic Quarrel 500 37 0 726
Wrapped Bundle of Deadly Prismatic Arrowheads Wrapped Bundle of Arrowshafts Deadly Prismatic Arrow 500 37 0 727
Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Greater Prismatic Atlatl Dart 500 37 0 728
Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Greater Prismatic Quarrel 500 37 0 729
Wrapped Bundle of Greater Prismatic Arrowheads Wrapped Bundle of Arrowshafts Greater Prismatic Arrow 500 37 0 730
Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Atlatl Dartshafts Prismatic Atlatl Dart 500 37 0 731
Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Quarrelshafts Prismatic Quarrel 500 37 0 732
Wrapped Bundle of Prismatic Arrowheads Wrapped Bundle of Arrowshafts Prismatic Arrow 500 37 0 733
Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Arrowshafts Deadly Frog Crotch Arrow 500 37 0 734
Infinite Deadly Broad Arrowheads Wrapped Bundle of Arrowshafts Deadly Broadhead Arrow 500 37 0 735
Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Arrowshafts Deadly Armor Piercing Arrow 500 37 0 736
Infinite Deadly Blunt Arrowheads Wrapped Bundle of Arrowshafts Deadly Blunt Arrow 500 37 0 737
Infinite Deadly Acid Arrowheads Wrapped Bundle of Arrowshafts Deadly Acid Arrow 500 37 0 738
Infinite Deadly Fire Arrowheads Wrapped Bundle of Arrowshafts Deadly Fire Arrow 500 37 0 739
Infinite Deadly Frost Arrowheads Wrapped Bundle of Arrowshafts Deadly Frost Arrow 500 37 0 740
Infinite Deadly Electric Arrowheads Wrapped Bundle of Arrowshafts Deadly Lightning Arrow 500 37 0 741
Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frog Crotch Quarrel 500 37 0 742
Infinite Deadly Frog Crotch Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frog Crotch Atlatl Dart 500 37 0 743
Infinite Deadly Broad Arrowheads Wrapped Bundle of Quarrelshafts Deadly Broadhead Quarrel 500 37 0 744
Infinite Deadly Broad Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Broadhead Atlatl Dart 500 37 0 745
Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Quarrelshafts Deadly Armor Piercing Quarrel 500 37 0 746
Infinite Deadly Armor Piercing Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Armor Piercing Atlatl Dart 500 37 0 747
Infinite Deadly Blunt Arrowheads Wrapped Bundle of Quarrelshafts Deadly Blunt Quarrel 500 37 0 748
Infinite Deadly Blunt Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Blunt Atlatl Dart 500 37 0 749
Infinite Deadly Acid Arrowheads Wrapped Bundle of Quarrelshafts Deadly Acid Quarrel 500 37 0 750
Infinite Deadly Acid Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Acid Atlatl Dart 500 37 0 751
Infinite Deadly Fire Arrowheads Wrapped Bundle of Quarrelshafts Deadly Fire Quarrel 500 37 0 752
Infinite Deadly Fire Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Fire Atlatl Dart 500 37 0 753
Infinite Deadly Frost Arrowheads Wrapped Bundle of Quarrelshafts Deadly Frost Quarrel 500 37 0 754
Infinite Deadly Frost Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Frost Atlatl Dart 500 37 0 755
Infinite Deadly Electric Arrowheads Wrapped Bundle of Quarrelshafts Deadly Lightning Quarrel 500 37 0 756
Infinite Deadly Electric Arrowheads Wrapped Bundle of Atlatl Dartshafts Deadly Lightning Atlatl Dart 500 37 0 757
Can't render this file because it has a wrong number of fields in line 2.

View file

@ -0,0 +1,289 @@
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// The ordered monster-element preferences from VTank's official
/// GameInfoDB. Name overrides win over CreatureType, matching e0.d(name).
/// Unknown targets retain VTank's final 0..6 element fallback order.
/// </summary>
internal static class VtankDamageDatabase
{
private static readonly MonsterDamageType[] Fallback =
[
MonsterDamageType.Pierce,
MonsterDamageType.Bludgeon,
MonsterDamageType.Slash,
MonsterDamageType.Acid,
MonsterDamageType.Electric,
MonsterDamageType.Cold,
MonsterDamageType.Fire,
];
private static readonly Dictionary<string, MonsterDamageType[]> Overrides =
ParseNames(OverrideData);
private static readonly Dictionary<int, MonsterDamageType[]> Species =
ParseSpecies(SpeciesData);
public static IReadOnlyList<MonsterDamageType> Preferences(
in PluginCombatTarget target)
{
if (!string.IsNullOrWhiteSpace(target.Name)
&& Overrides.TryGetValue(target.Name, out MonsterDamageType[]? exact))
{
return exact;
}
// Zero is also the plugin contract's "not appraised" sentinel.
if (target.SpeciesId != 0
&& Species.TryGetValue(target.SpeciesId, out MonsterDamageType[]? species))
{
return species;
}
return Fallback;
}
public static int PreferenceIndex(
in PluginCombatTarget target,
MonsterDamageType damage)
{
IReadOnlyList<MonsterDamageType> preferences = Preferences(target);
for (int i = 0; i < preferences.Count; i++)
{
if (preferences[i] == damage)
return i;
}
for (int i = 0; i < Fallback.Length; i++)
{
if (Fallback[i] == damage)
return preferences.Count + i;
}
return int.MaxValue;
}
private static Dictionary<string, MonsterDamageType[]> ParseNames(
string data)
{
var result = new Dictionary<string, MonsterDamageType[]>(
StringComparer.OrdinalIgnoreCase);
foreach (ReadOnlySpan<char> line in data.AsSpan().EnumerateLines())
{
int separator = line.IndexOf('|');
if (separator <= 0)
continue;
result[line[..separator].ToString()] = ParseElements(
line[(separator + 1)..]);
}
return result;
}
private static Dictionary<int, MonsterDamageType[]> ParseSpecies(
string data)
{
var result = new Dictionary<int, MonsterDamageType[]>();
foreach (ReadOnlySpan<char> line in data.AsSpan().EnumerateLines())
{
int separator = line.IndexOf('|');
if (separator <= 0
|| !int.TryParse(line[..separator], out int species))
{
continue;
}
result[species] = ParseElements(line[(separator + 1)..]);
}
return result;
}
private static MonsterDamageType[] ParseElements(ReadOnlySpan<char> text)
{
var result = new List<MonsterDamageType>(7);
foreach (Range range in text.Split(';'))
{
if (!int.TryParse(text[range], out int raw))
continue;
MonsterDamageType mapped = raw switch
{
0 => MonsterDamageType.Pierce,
1 => MonsterDamageType.Bludgeon,
2 => MonsterDamageType.Slash,
3 => MonsterDamageType.Acid,
4 => MonsterDamageType.Electric,
5 => MonsterDamageType.Cold,
6 => MonsterDamageType.Fire,
_ => MonsterDamageType.None,
};
if (mapped != MonsterDamageType.None && !result.Contains(mapped))
result.Add(mapped);
}
return [.. result];
}
private const string OverrideData = """
Magma Golem|5;1;0;2
Mist Golem|5;4;3;6;2;0;1
Nubilous Golem|4;5;3;2;0;1
Plasma Golem|4;5;3;2;0;1
Vapor Golem|5;4;15;4;3;6;2;0;1
Damaged Glacial Golem|6;1;0;2
Fractured Glacial Golem|6;1;0;2
Tanada Nanjou Shou-jen|3;4;6;5
Disgraced Nanjou Shou-jen|3;4;6;5
Magma Golem Exarch|5;1;0;2
Pillar of Fire|5;2;0
Infused Blood Golem|5;3;4;1;0;6;2
Infused Empyrean Blood Golem|5;3;4;2;6;0;1
Sapphire Golem|0;3;1;5;6;4;2
High Priestess Xik Minru|1;2;0
Contained Rift|2;1;0
Ebon Rift|2;1;0
Fallen Rift|2;1;0
Narrow Rift|2;1;0
Quiddity Rift|2;1;0
Shallow Rift|2;1;0
Tenebrous Rift|2;1;0
Umbral Rift|2;1;0
Unstable Rift|2;1;0
Aqueous Golem|4;6;5;3;2;1;0
Wave Golem|4;6;5;3;2;1;0
Unstable Magma Golem|5;1;0;2
Behemoth of Tenkarrdun|5;1;0;2
Small Magma Golem|5;1;0;2
Atlan's Crafting Golem|5;1;0;2
Bur Lizk|5;4;0;2
Dust Golem|5;4;6;3;0;1;2
Ancient Magma Golem|5;1;0;2
Frozen Ice Golem|6;1;0;2
Frozen Glacial Golem|6;1;0;2
Forge Golem|5;4;3;1;0;2
Frozen Gearknight|6
Diaphanous Nephol Golem|5;4;3;6;2;0;1
Tenuous Nephol Golem|5;4;3;6;2;0;1
Turbid Nephol Golem|5;4;3;6;2;0;1
Wall of Ice|6;1;0;2;3;4
Scold|5;1;0;2
Scold Chunk|5;1;0;2
Scold Lump|5;1;0;2
Freezing Mist Golem|5;4;3;6;2;0;1
Frost Golem|6;4;5;3
Elite Guardian|5;6
Enraged Ancient Soul|6;3;1;4;5;2;0
Mudmouth|6;4;3;5;1;0;2
Fiery Defender|5;2;4;1;0;3
Follower of Deewain|4;3;1;0;5;2;6
Chilled Defender|4;3;6;1;2;0;5
Charged Defender|5;3;4;2;1;0
Iron Golem Samurai|3;4;5;6;2;1;0
Clay Golem Samurai|1;5;6;4;3;2;0
Bronze Golem Samurai|4;3;5;6;2;1;0
Spectral Nanjou Shou-jen|1;6;2;3;4;5;0
Spectral Samurai|5;4;3;2;1;6;0
Spectral Claw Master|1;6;2;3;4;5;0
""";
private const string SpeciesData = """
0|2
1|1;0;2;5;6;4;3
2|4;6;5;2;0;1;3
3|6;1;2;3;5;0;4
4|6;1;4;3;2;5;0
5|4;3;2;0;1;5;6
6|2;0;5;3;1;4;6
7|0;2;1;6;3;5;4
8|6;0;1;5;3;2;4
9|1;2;0;3;6;5;4
10|1;4;2;0;5;3;6
11|2
12|2
13|1;3;0;5;6;4;2
14|6;3;2;1;4;0;5
15|2;0;1
16|5;2;4;3;0;1;6
17|2;0;3;1;5;4;6
18|2
19|6;0;1;2;3;5;4
20|2;0;1;3;4;5
21|0;4;6;3
22|6;2;1;4;0;3;5
23|6;0;1;3;2;5;4
24|6;3;2
25|2
26|5;2;0;6
27|0;2;1
28|5;3;0
29|4;5;2
30|1;2;0
31|6;0;1;2;3;4;5
32|5;1;3
33|2;0;1
34|2;0;1
35|1;0;2
36|2;6;0
37|2
38|5;2;0
39|6;2;1
40|2
41|2
42|3;2;0
43|2
44|2;0;1
45|2;5;3
46|6;0;2;1;3;4;5
47|1;0;2
48|5;2;0;3;1;6;4
49|6;2;0;1
50|2;6;1
51|2;1;0
52|6;2;1;0
53|5;1;2;4;6;3;0
54|5;2;0;1
55|1;5;4;2;6;3;0
56|5;1;3
57|2;0;5;3;1;4;6
58|2;0;5;3;1;4;6
59|5;2;0;3;1;6;4
60|4;2;0
61|6;2;0
62|2;0;1
63|3;4;6;5;1;2;0
64|2
65|2
66|2
67|2
68|2
69|2
70|4;3;0;2;1
71|1;5;4;0;2;6;3
72|2
73|2
74|2
75|5;0;2;6;1;4;3
76|2
77|6;2;0;1;3;4;5
78|4;6;5;2;3;0;1
79|2;0;6;5;4;3;1
80|6;2;1;0
81|1;0;6;2;3;4;5
82|2;1;0
83|4;2;0;1
84|1;2;0
85|2
86|2;0;1
87|2
88|1;0;2
89|0;1;2
90|2
91|2
92|1;0;2
93|2
94|2
95|6;2;1;0
96|2
-1|1;0;2;3;4;5;6
97|6
98|2;0;1
99|3;4;1;0;6;5;2
100|6;3;2;0;1;5;4
101|3;1;6;0;2;4;5
""";
}

View file

@ -0,0 +1,446 @@
using System.Globalization;
using System.Text;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// One length-delimited VTClassic requirement. The payload is retained
/// verbatim so a newer VTClassic requirement can survive an acdream edit even
/// when MossTank does not understand that requirement yet.
/// </summary>
internal sealed class VtankLootRequirement
{
public int Type { get; set; }
public string Payload { get; set; } = string.Empty;
}
internal sealed class VtankSalvageCombineSettings
{
public string DefaultCombineString { get; set; } = "1-6, 7-8, 9, 10";
public Dictionary<int, string> MaterialCombineStrings { get; set; } =
CreateVtankDefaults();
public Dictionary<int, int> MaterialValueModeValues { get; set; } = [];
public VtankSalvageCombineSettings Clone() => new()
{
DefaultCombineString = DefaultCombineString,
MaterialCombineStrings = new Dictionary<int, string>(
MaterialCombineStrings),
MaterialValueModeValues = new Dictionary<int, int>(
MaterialValueModeValues),
};
private static Dictionary<int, string> CreateVtankDefaults()
{
const string oneThroughTen = "1-10";
int[] materials =
[
10, 14, 16, 17, 18, 19, 22, 25, 29, 30, 36, 37, 41,
47, 35, 27, 26, 21, 15, 13,
50, 49, 34,
52, 51,
];
return materials.ToDictionary(
static material => material,
static _ => oneThroughTen);
}
}
internal sealed class VtankLootExtraBlock
{
public string Type { get; set; } = string.Empty;
public string Payload { get; set; } = string.Empty;
}
internal sealed class VtankLootProfile
{
public int SourceVersion { get; set; } = 1;
public List<LootRule> Rules { get; set; } = [];
public VtankSalvageCombineSettings SalvageCombine { get; set; } = new();
public List<VtankLootExtraBlock> UnknownBlocks { get; set; } = [];
}
/// <summary>
/// Independent reader/writer for VTClassic's public <c>UTL 1</c> format.
/// The format was recovered from the MIT-licensed VTClassic source; this is a
/// clean implementation using MossTank's own model and parser.
/// </summary>
internal static class VtankLootProfileSerializer
{
private const string Header = "UTL";
private const int CurrentVersion = 1;
private const string SalvageBlock = "SalvageCombine";
private const int DisabledRuleType = 9999;
private static readonly string NewLine = "\r\n";
public static bool TryRead(
string? source,
out VtankLootProfile profile,
out string error)
{
profile = new VtankLootProfile();
error = string.Empty;
if (string.IsNullOrEmpty(source))
{
error = "The VTClassic loot profile is empty.";
return false;
}
try
{
var reader = new CharacterReader(source);
string first = reader.ReadLine();
int version;
int count;
if (string.Equals(first, Header, StringComparison.Ordinal))
{
version = ParseInt(reader.ReadLine(), "profile version");
if (version is < 0 or > CurrentVersion)
throw new FormatException(
$"VTClassic loot profile version {version} is not supported.");
count = ParseCount(reader.ReadLine(), "rule count", 100_000);
}
else
{
version = 0;
count = ParseCount(first, "rule count", 100_000);
}
profile.SourceVersion = version;
for (int index = 0; index < count; index++)
profile.Rules.Add(ReadRule(reader, version, index));
while (!reader.End)
{
string blockType = reader.ReadLine();
if (blockType.Length == 0 && reader.End)
break;
int length = ParseCount(
reader.ReadLine(),
$"{blockType} block length",
16 * 1024 * 1024);
string payload = reader.ReadCharacters(length);
if (string.Equals(
blockType,
SalvageBlock,
StringComparison.Ordinal))
{
profile.SalvageCombine = ReadSalvage(payload);
}
else
{
profile.UnknownBlocks.Add(new VtankLootExtraBlock
{
Type = blockType,
Payload = payload,
});
}
}
return true;
}
catch (FormatException failure)
{
profile = new VtankLootProfile();
error = failure.Message;
return false;
}
}
public static string Write(VtankLootProfile profile)
{
ArgumentNullException.ThrowIfNull(profile);
var output = new StringBuilder();
AppendLine(output, Header);
AppendLine(output, CurrentVersion);
AppendLine(output, profile.Rules.Count);
foreach (LootRule rule in profile.Rules)
WriteRule(output, rule);
WriteBlock(output, SalvageBlock, WriteSalvage(profile.SalvageCombine));
foreach (VtankLootExtraBlock block in profile.UnknownBlocks)
{
if (string.IsNullOrEmpty(block.Type)
|| string.Equals(
block.Type,
SalvageBlock,
StringComparison.Ordinal))
{
continue;
}
WriteBlock(output, block.Type, block.Payload ?? string.Empty);
}
return output.ToString();
}
private static LootRule ReadRule(
CharacterReader reader,
int version,
int ruleIndex)
{
string name = reader.ReadLine();
string customExpression = version >= 1
? reader.ReadLine()
: string.Empty;
string[] fields = reader.ReadLine().Split(';');
if (fields.Length < 2)
throw new FormatException($"Loot rule {ruleIndex + 1} has an invalid header.");
int priority = ParseInt(fields[0], $"rule {ruleIndex + 1} priority");
int actionValue = ParseInt(fields[1], $"rule {ruleIndex + 1} action");
if (actionValue is < 0 or > 10)
throw new FormatException($"Loot rule {ruleIndex + 1} has action {actionValue}.");
var rule = new LootRule
{
Name = string.IsNullOrWhiteSpace(name) ? $"Rule {ruleIndex + 1}" : name,
Expression = "*",
CustomExpression = customExpression,
Action = (LootAction)actionValue,
Priority = priority,
};
if (rule.Action == LootAction.KeepUpTo)
{
rule.KeepCount = Math.Max(
0,
ParseInt(reader.ReadLine(), $"rule {ruleIndex + 1} keep count"));
}
for (int field = 2; field < fields.Length; field++)
{
int type = ParseInt(
fields[field],
$"rule {ruleIndex + 1} requirement type");
string payload;
if (version >= 1)
{
int length = ParseCount(
reader.ReadLine(),
$"rule {ruleIndex + 1} requirement length",
16 * 1024 * 1024);
payload = reader.ReadCharacters(length);
}
else
{
int lines = LegacyPayloadLineCount(type);
if (lines < 0)
throw new FormatException(
$"Version 0 loot rule {ruleIndex + 1} uses unknown requirement {type}.");
var legacy = new StringBuilder();
for (int line = 0; line < lines; line++)
AppendLine(legacy, reader.ReadLine());
payload = legacy.ToString();
}
rule.VtankRequirements.Add(new VtankLootRequirement
{
Type = type,
Payload = payload,
});
}
return rule;
}
private static void WriteRule(StringBuilder output, LootRule rule)
{
AppendLine(output, SingleLine(rule.Name, "Rule"));
AppendLine(output, SingleLine(rule.CustomExpression, string.Empty));
IReadOnlyList<VtankLootRequirement> requirements =
ExportRequirements(rule);
var header = new StringBuilder();
header.Append(rule.Priority.ToString(CultureInfo.InvariantCulture));
header.Append(';');
int action = (int)rule.Action is >= 0 and <= 10
? (int)rule.Action
: (int)LootAction.NoLoot;
header.Append(action.ToString(CultureInfo.InvariantCulture));
foreach (VtankLootRequirement requirement in requirements)
{
header.Append(';');
header.Append(requirement.Type.ToString(CultureInfo.InvariantCulture));
}
AppendLine(output, header.ToString());
if (action == (int)LootAction.KeepUpTo)
AppendLine(output, Math.Max(0, rule.KeepCount));
foreach (VtankLootRequirement requirement in requirements)
{
string payload = NormalizePayload(requirement.Payload);
AppendLine(output, payload.Length);
output.Append(payload);
}
}
private static IReadOnlyList<VtankLootRequirement> ExportRequirements(
LootRule rule)
{
if (rule.VtankRequirements.Count > 0)
return rule.VtankRequirements;
// VTClassic stores CustomExpression for editors but its classifier does
// not execute it. An arbitrary MossTank expression therefore cannot be
// exported as an empty requirement set (which VTClassic treats as
// match-all); make the legacy copy visibly safe instead.
return
[
new VtankLootRequirement
{
Type = DisabledRuleType,
Payload = "true" + NewLine,
},
];
}
private static VtankSalvageCombineSettings ReadSalvage(string payload)
{
var reader = new CharacterReader(payload);
_ = ParseInt(reader.ReadLine(), "salvage block version");
var result = new VtankSalvageCombineSettings
{
DefaultCombineString = reader.ReadLine(),
MaterialCombineStrings = [],
MaterialValueModeValues = [],
};
int strings = ParseCount(
reader.ReadLine(),
"salvage material rule count",
10_000);
for (int index = 0; index < strings; index++)
{
int material = ParseInt(reader.ReadLine(), "salvage material id");
result.MaterialCombineStrings[material] = reader.ReadLine();
}
if (reader.End)
return result;
int values = ParseCount(
reader.ReadLine(),
"salvage value-mode count",
10_000);
for (int index = 0; index < values; index++)
{
int material = ParseInt(reader.ReadLine(), "salvage value material id");
result.MaterialValueModeValues[material] = ParseInt(
reader.ReadLine(),
"salvage value-mode value");
}
return result;
}
private static string WriteSalvage(VtankSalvageCombineSettings? settings)
{
settings ??= new VtankSalvageCombineSettings();
var output = new StringBuilder();
AppendLine(output, 1);
AppendLine(output, SingleLine(
settings.DefaultCombineString,
"1-6, 7-8, 9, 10"));
AppendLine(output, settings.MaterialCombineStrings.Count);
foreach ((int material, string combine) in
settings.MaterialCombineStrings.OrderBy(static pair => pair.Key))
{
AppendLine(output, material);
AppendLine(output, SingleLine(combine, string.Empty));
}
AppendLine(output, settings.MaterialValueModeValues.Count);
foreach ((int material, int value) in
settings.MaterialValueModeValues.OrderBy(static pair => pair.Key))
{
AppendLine(output, material);
AppendLine(output, value);
}
return output.ToString();
}
private static void WriteBlock(
StringBuilder output,
string type,
string payload)
{
string normalized = NormalizePayload(payload);
AppendLine(output, SingleLine(type, "Unknown"));
AppendLine(output, normalized.Length);
output.Append(normalized);
}
private static string NormalizePayload(string? payload) =>
(payload ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Replace("\n", NewLine, StringComparison.Ordinal);
private static string SingleLine(string? value, string fallback)
{
string normalized = value ?? fallback;
int lineEnd = normalized.IndexOfAny(['\r', '\n']);
return lineEnd < 0 ? normalized : normalized[..lineEnd];
}
private static int LegacyPayloadLineCount(int type) => type switch
{
0 => 1,
1 => 2,
2 or 3 or 4 or 5 or 11 or 12 or 13 or 2003 or 2005 => 2,
6 or 7 or 8 or 10 or 1001 or 1002 or 1003 or 2000 or 2001
or 2006 or 2007 or 9999 => 1,
9 or 1004 or 2008 => 3,
14 => 5,
15 or 16 => 6,
17 or 1000 => 2,
_ => -1,
};
private static int ParseCount(string value, string field, int maximum)
{
int parsed = ParseInt(value, field);
if (parsed < 0 || parsed > maximum)
throw new FormatException($"Invalid {field}: {value}.");
return parsed;
}
private static int ParseInt(string value, string field) =>
int.TryParse(
value,
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out int parsed)
? parsed
: throw new FormatException($"Invalid {field}: {value}.");
private static void AppendLine(StringBuilder output, string value) =>
output.Append(value).Append(NewLine);
private static void AppendLine(StringBuilder output, int value) =>
AppendLine(output, value.ToString(CultureInfo.InvariantCulture));
private sealed class CharacterReader(string source)
{
private int _position;
public bool End => _position >= source.Length;
public string ReadLine()
{
if (End)
throw new FormatException("The VTClassic loot profile ended unexpectedly.");
int start = _position;
while (_position < source.Length
&& source[_position] is not ('\r' or '\n'))
{
_position++;
}
string line = source[start.._position];
if (_position < source.Length && source[_position] == '\r')
_position++;
if (_position < source.Length && source[_position] == '\n')
_position++;
return line;
}
public string ReadCharacters(int count)
{
if (count < 0 || count > source.Length - _position)
throw new FormatException("A VTClassic length-delimited block is truncated.");
string value = source.Substring(_position, count);
_position += count;
return value;
}
}
}

View file

@ -0,0 +1,576 @@
using System.Globalization;
using System.Text.RegularExpressions;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Executes VTClassic's typed loot requirements as an AND set.</summary>
internal static class VtankLootRequirementEvaluator
{
private const uint VtankIntBase = 218_103_808u;
private const uint VtankDoubleBase = 167_772_160u;
private static readonly IReadOnlyDictionary<uint, (uint Key, int Bonus)>
IntSpellBonuses = new Dictionary<uint, (uint, int)>
{
[2598] = (VtankIntBase + 34, 2),
[2586] = (VtankIntBase + 34, 4),
[4661] = (VtankIntBase + 34, 7),
[6089] = (VtankIntBase + 34, 10),
[2604] = (28, 20),
[2592] = (28, 40),
[4667] = (28, 60),
[6095] = (28, 80),
};
private static readonly IReadOnlyDictionary<uint, (uint Key, double Bonus)>
DoubleSpellBonuses = new Dictionary<uint, (uint, double)>
{
[3251] = (152, .01), [3250] = (152, .03),
[4670] = (152, .05), [6098] = (152, .07),
[2603] = (VtankDoubleBase + 12, .03),
[2591] = (VtankDoubleBase + 12, .05),
[4666] = (VtankDoubleBase + 12, .07),
[6094] = (VtankDoubleBase + 12, .09),
[2600] = (29, .03), [3985] = (29, .04),
[2588] = (29, .05), [4663] = (29, .07), [6091] = (29, .09),
[3201] = (144, 1.05), [3199] = (144, 1.10),
[3202] = (144, 1.15), [3200] = (144, 1.20),
[6086] = (144, 1.25), [6087] = (144, 1.30),
};
private static readonly IReadOnlyDictionary<string, int[]> ArmorColorSlots =
new Dictionary<string, int[]>(StringComparer.Ordinal)
{
["Amuli Coat (Chest)"] = [0],
["Amuli Coat (Collar/Shoulder)"] = [1, 2],
["Amuli Coat (Arms/Trim)"] = [3, 4, 5, 6, 7],
["Amuli Legs (Base)"] = [0, 1],
["Amuli Legs (Trim)"] = [2, 3],
["Celdon (Base)"] = [0],
["Celdon (Veins)"] = [1, 2],
["Chiran Coat (Base/Arms)"] = [0, 1],
["Chiran Coat (Stripes)"] = [2, 3, 4],
["Chiran Legs (Girth)"] = [1],
["Chiran Legs (Legs)"] = [2, 3],
["Chiran Legs (Trim)"] = [0],
["Chiran Helm (Horns)"] = [0],
["Chiran Helm (Base)"] = [1],
["Haebrean BP (Chest) *"] = [0],
["Haebrean BP (Ornaments)"] = [1],
["Haebrean BP (Trim)"] = [2],
["Haebrean Girth (Base) *"] = [0],
["Haebrean Girth (Belt/Scales)"] = [1, 2],
["Haebrean Helm (Base)"] = [0],
["Haebrean Helm (Mask)"] = [1],
["Haebrean Pauldrons (Base) *"] = [0],
["Haebrean Pauldrons (Ornaments)"] = [1],
["Lorica BP (Veins)"] = [0, 1],
["Lorica BP (Base)"] = [2, 3],
["Lorica BP (Neck/Trim) *"] = [4],
["Lorica Legs (Base)"] = [0],
["Lorica Legs (Knees/Belt/Crotch) *"] = [1, 2],
["Lorica Legs (Legs) *"] = [3],
["Nariyid BP (Circle/Lines)"] = [0, 1],
["Nariyid BP (Base)"] = [2],
["Nariyid BP (Shoulders)"] = [3],
["Nariyid Girth (Base) *"] = [0],
["Nariyid Girth (Belt/Lines)"] = [2],
["Nariyid Girth (Ornaments)"] = [3],
["Nariyid Sleeves (Shoulders)"] = [0],
["Nariyid Sleeves (Upper Arm)"] = [1, 2],
["Nariyid Sleeves (Lower Arm)"] = [3],
["Olthoi BP (Base)"] = [0],
["Olthoi BP (Veins)"] = [1],
["Olthoi Alduressa Legs (Girth: Base)"] = [0, 1, 2],
["Olthoi Alduressa Legs (Girth: Lines)"] = [3],
["Olthoi Alduressa Legs (Legs: Lines)"] = [4, 5],
["Olthoi Amuli Coat (Base) *"] = [0, 1],
["Olthoi Amuli Coat (Trim)"] = [2],
["Olthoi Amuli Coat (Shoulders)"] = [3],
["Olthoi Amuli Legs (Trim)"] = [6, 7, 8],
["Olthoi Koujia Kabuton (Base)"] = [0],
["Olthoi Koujia Kabuton (Horns)"] = [1],
["Olthoi Koujia Legs (Base)"] = [0, 1, 2],
["Olthoi Koujia Legs (Sides/Shins)"] = [3, 4, 5],
["Scalemail Cuirass (Base)"] = [0],
["Scalemail Cuirass (Bumps)"] = [1],
["Scalemail Cuirass (Belt)"] = [2],
["Tenassa Legs (Line at Side)"] = [0],
["Tenassa Legs (Base)"] = [1],
["Tenassa Legs (Hilight)"] = [2],
["Tenassa BP (Shoulders)"] = [0],
["Tenassa BP (Base)"] = [1],
["Yoroi Cuirass (Base)"] = [0, 1],
["Yoroi Cuirass (Belt)"] = [2],
["Yoroi Girth (Base)"] = [0],
["Yoroi Girth (Belt)"] = [1],
};
public static bool IsMatch(
IReadOnlyList<VtankLootRequirement> requirements,
in PluginInventoryItem item,
in PluginItemProperties properties,
IPluginHost? host,
out string? error)
{
try
{
foreach (VtankLootRequirement requirement in requirements)
{
if (!IsMatch(requirement, item, properties, host))
{
error = null;
return false;
}
}
error = null;
return true;
}
catch (Exception failure) when (
failure is FormatException or ArgumentException or OverflowException)
{
error = failure.Message;
return false;
}
}
private static bool IsMatch(
VtankLootRequirement requirement,
in PluginInventoryItem item,
in PluginItemProperties properties,
IPluginHost? host)
{
string[] values = Lines(requirement.Payload);
return requirement.Type switch
{
0 => SpellNames(item, host).Any(name => Rx(values, 0).IsMatch(name)),
1 => Rx(values, 0).IsMatch(StringValue(
U32(values, 1), item, properties)),
2 => IntValue(U32(values, 1), item, properties) <= I32(values, 0),
3 => IntValue(U32(values, 1), item, properties) >= I32(values, 0),
4 => (float)DoubleValue(U32(values, 1), item, properties)
<= (float)F64(values, 0),
5 => (float)DoubleValue(U32(values, 1), item, properties)
>= (float)F64(values, 0),
// VTClassic deliberately retired this requirement; its own Match
// method always returns false.
6 => false,
7 => (int)item.ObjectClass == I32(values, 0),
8 => item.AppraisedSpellIds.Count >= I32(values, 0),
9 => SpellMatch(values, item, host),
10 => MinimumDamage(item) >= F64(values, 0),
11 => (IntValue(U32(values, 1), item, properties)
& I32(values, 0)) > 0,
12 => IntValue(U32(values, 1), item, properties) == I32(values, 0),
13 => IntValue(U32(values, 1), item, properties) != I32(values, 0),
14 => ColorMatch(values, item.Palettes),
15 => ArmorColorMatch(values, item.Palettes),
16 => SlotColorMatch(values, item.Palettes),
17 => ExactPalette(values, item.Palettes),
1000 => CharacterSkill(host, U32(values, 1), buffed: true)
>= I32(values, 0),
1001 => (host?.Automation.Character.MainPackFreeSlots ?? 0)
>= I32(values, 0),
1002 => (host?.Automation.Character.Level ?? 0) >= I32(values, 0),
1003 => (host?.Automation.Character.Level ?? 0) <= I32(values, 0),
1004 => CharacterBaseSkillRange(values, host),
2000 => BuffedMedianDamage(item, properties) >= F64(values, 0),
2001 => BuffedMissileDamage(item, properties) >= F64(values, 0),
2003 => BuffedInt(
U32(values, 1), item, properties) >= F64(values, 0),
2005 => (float)BuffedDouble(
U32(values, 1), item, properties) >= (float)F64(values, 0),
2006 => BuffedTinkedDamage(item, properties) >= F64(values, 0),
2007 => TotalRatings(item, properties) >= F64(values, 0),
2008 => CanReachTarget(values, item, properties),
9999 => !Bool(values, 0),
_ => false,
};
}
private static bool SpellMatch(
string[] values,
in PluginInventoryItem item,
IPluginHost? host)
{
Regex include = Rx(values, 0);
Regex exclude = Rx(values, 1);
bool excludeEmpty = Value(values, 1).Trim().Length == 0;
int required = I32(values, 2);
int count = 0;
foreach (string name in SpellNames(item, host))
{
if (include.IsMatch(name)
&& (excludeEmpty || !exclude.IsMatch(name))
&& ++count >= required)
{
return true;
}
}
return false;
}
private static bool ColorMatch(
string[] values,
IReadOnlyList<PluginPaletteInfo> palettes)
{
for (int index = 0; index < palettes.Count; index++)
{
if (SimilarColor(values, palettes[index]))
return true;
}
return false;
}
private static bool ArmorColorMatch(
string[] values,
IReadOnlyList<PluginPaletteInfo> palettes)
{
if (!ArmorColorSlots.TryGetValue(Value(values, 5), out int[]? slots))
return false;
foreach (int slot in slots)
{
if (slot >= 0 && slot < palettes.Count
&& SimilarColor(values, palettes[slot]))
{
return true;
}
}
return false;
}
private static bool SlotColorMatch(
string[] values,
IReadOnlyList<PluginPaletteInfo> palettes)
{
int slot = I32(values, 5);
return slot >= 0 && slot < palettes.Count
&& SimilarColor(values, palettes[slot]);
}
private static bool ExactPalette(
string[] values,
IReadOnlyList<PluginPaletteInfo> palettes)
{
int slot = I32(values, 0);
uint expected = U32(values, 1) & 0x00FF_FFFFu;
return slot >= 0 && slot < palettes.Count
&& (palettes[slot].PaletteId & 0x00FF_FFFFu) == expected;
}
private static bool SimilarColor(
string[] values,
in PluginPaletteInfo palette)
{
Hsv(
checked((byte)I32(values, 0)),
checked((byte)I32(values, 1)),
checked((byte)I32(values, 2)),
out double targetHue,
out double targetSaturation,
out double targetValue);
Hsv(
palette.Red,
palette.Green,
palette.Blue,
out double hue,
out double saturation,
out double value);
if (Math.Abs(hue - targetHue) > F64(values, 3))
return false;
double sd = saturation - targetSaturation;
double vd = value - targetValue;
return Math.Sqrt((sd * sd) + (vd * vd)) <= F64(values, 4);
}
private static void Hsv(
byte red,
byte green,
byte blue,
out double hue,
out double saturation,
out double value)
{
int maximum = Math.Max(red, Math.Max(green, blue));
int minimum = Math.Min(red, Math.Min(green, blue));
int delta = maximum - minimum;
if (delta == 0)
{
hue = 0d;
}
else if (maximum == red)
{
hue = 60d * (green - blue) / delta;
if (hue < 0d)
hue += 360d;
}
else if (maximum == green)
{
hue = (60d * (blue - red) / delta) + 120d;
}
else
{
hue = (60d * (red - green) / delta) + 240d;
}
saturation = maximum == 0 ? 0d : 1d - ((double)minimum / maximum);
value = maximum / 255d;
}
private static IEnumerable<string> SpellNames(
PluginInventoryItem item,
IPluginHost? host)
{
if (host is null)
yield break;
foreach (uint spellId in item.AppraisedSpellIds)
{
if (host.Automation.Spells.TryGet(spellId, out PluginSpellInfo spell))
yield return spell.Name;
}
}
private static int CharacterSkill(
IPluginHost? host,
uint skillId,
bool buffed)
{
if (host?.Automation.Character.TryGetSkill(
skillId,
out PluginSkillInfo skill) != true)
{
return 0;
}
return checked((int)(buffed ? skill.Current : skill.Base));
}
private static bool CharacterBaseSkillRange(
string[] values,
IPluginHost? host)
{
int level = CharacterSkill(host, U32(values, 0), buffed: false);
return level >= I32(values, 1) && level <= I32(values, 2);
}
private static string StringValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
{
1 => item.Name,
_ => properties.Strings?.TryGetValue(key, out string? value) == true
? value
: string.Empty,
};
private static int IntValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
{
5 => item.Burden,
19 => item.Value,
105 => checked((int)item.Workmanship),
107 => item.ItemCurrentMana,
108 => item.ItemMaximumMana,
131 => checked((int)item.MaterialType),
VtankIntBase + 0 => checked((int)item.WeenieClassId),
VtankIntBase + 2 => checked((int)item.ContainerObjectId),
VtankIntBase + 4 => item.ItemsCapacity,
VtankIntBase + 5 => item.ContainersCapacity,
VtankIntBase + 6 => item.StackSize,
VtankIntBase + 7 => item.MaximumStackSize,
VtankIntBase + 8 => checked((int)item.SpellId),
VtankIntBase + 9 => item.ContainerSlot,
VtankIntBase + 10 => checked((int)item.WielderObjectId),
VtankIntBase + 11 => checked((int)item.EquippedLocation),
VtankIntBase + 14 => checked((int)item.ValidLocations),
VtankIntBase + 18 => checked((int)item.Useability),
VtankIntBase + 23 => checked((int)item.PublicFlags),
VtankIntBase + 31 => item.CombatUse,
VtankIntBase + 32 => item.WeaponSkill,
VtankIntBase + 33 => item.DamageType,
VtankIntBase + 34 => item.Damage,
VtankIntBase + 38 => item.AppraisedSpellIds.Count,
_ => properties.Ints?.TryGetValue(key, out int value) == true
? value
: 0,
};
private static double DoubleValue(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties) => key switch
{
VtankDoubleBase + 9 => item.Workmanship,
VtankDoubleBase + 11 => item.DamageVariance,
VtankDoubleBase + 12 => RawFloat(properties, 62),
VtankDoubleBase + 14 => RawFloat(properties, 63),
_ => RawFloat(properties, key),
};
private static double RawFloat(in PluginItemProperties properties, uint key) =>
properties.Floats?.TryGetValue(key, out double value) == true ? value : 0d;
private static int BuffedInt(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties)
{
int value = IntValue(key, item, properties);
foreach (uint spellId in item.AppraisedSpellIds)
{
if (IntSpellBonuses.TryGetValue(spellId, out var bonus)
&& bonus.Key == key)
{
value += bonus.Bonus;
}
}
return value;
}
private static double BuffedDouble(
uint key,
in PluginInventoryItem item,
in PluginItemProperties properties)
{
double value = DoubleValue(key, item, properties);
foreach (uint spellId in item.AppraisedSpellIds)
{
if (!DoubleSpellBonuses.TryGetValue(spellId, out var bonus)
|| bonus.Key != key)
{
continue;
}
value = (int)bonus.Bonus == 1 ? value * bonus.Bonus : value + bonus.Bonus;
}
return value;
}
private static double MinimumDamage(in PluginInventoryItem item) =>
item.Damage - (item.DamageVariance * item.Damage);
private static double BuffedMedianDamage(
in PluginInventoryItem item,
in PluginItemProperties properties)
{
int maximum = BuffedInt(VtankIntBase + 34, item, properties);
double minimum = maximum - (item.DamageVariance * maximum);
return (minimum + maximum) / 2d;
}
private static double BuffedMissileDamage(
in PluginInventoryItem item,
in PluginItemProperties properties) =>
BuffedInt(VtankIntBase + 34, item, properties)
+ (((BuffedDouble(VtankDoubleBase + 14, item, properties) - 1d)
* 100d) / 3d)
+ BuffedInt(204, item, properties);
private static double BuffedTinkedDamage(
in PluginInventoryItem item,
in PluginItemProperties properties)
{
double variance = item.DamageVariance;
int maximum = BuffedInt(VtankIntBase + 34, item, properties);
int tinks = Math.Max(10 - IntValue(171, item, properties), 0);
if (IntValue(179, item, properties) == 0)
tinks--;
if (IntValue(131, item, properties) == 0)
tinks = 0;
for (int index = 1; index <= tinks; index++)
{
double iron = DamageOverTime(maximum + 25, variance);
double granite = DamageOverTime(maximum + 24, variance * .8d);
if (iron >= granite)
maximum++;
else
variance *= .8d;
}
return DamageOverTime(maximum + 24, variance);
}
private static int TotalRatings(
in PluginInventoryItem item,
in PluginItemProperties properties) =>
item.GearDamage + item.GearDamageResistance
+ item.GearCriticalChance + item.GearCriticalResistance
+ item.GearCriticalDamage + item.GearCriticalDamageResistance
+ IntValue(376, item, properties) + IntValue(379, item, properties);
private static bool CanReachTarget(
string[] values,
in PluginInventoryItem item,
in PluginItemProperties properties)
{
double targetDamage = F64(values, 0);
double targetDefense = F64(values, 1);
double targetAttack = F64(values, 2);
double defense = BuffedDouble(29, item, properties);
double attack = BuffedDouble(VtankDoubleBase + 12, item, properties);
double variance = item.DamageVariance;
int maximum = BuffedInt(VtankIntBase + 34, item, properties);
int tinks = Math.Max(10 - IntValue(171, item, properties), 0);
if (IntValue(179, item, properties) == 0)
tinks--;
if (IntValue(131, item, properties) == 0)
tinks = 0;
for (int index = 1; index <= tinks; index++)
{
if (defense < targetDefense)
defense += .01d;
else if (attack < targetAttack)
attack += .01d;
else if (DamageOverTime(maximum + 25, variance)
>= DamageOverTime(maximum + 24, variance * .8d))
maximum++;
else
variance *= .8d;
}
return DamageOverTime(maximum + 24, variance) >= targetDamage
&& defense >= targetDefense
&& attack >= targetAttack;
}
private static double DamageOverTime(int maximum, double variance) =>
maximum * ((.9d * (2d - variance) / 2d) + .2d);
private static string[] Lines(string? payload) =>
(payload ?? string.Empty)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Split('\n');
private static string Value(string[] values, int index) =>
index >= 0 && index < values.Length
? values[index]
: throw new FormatException("A VTClassic loot requirement is truncated.");
private static Regex Rx(string[] values, int index) => new(
Value(values, index),
RegexOptions.CultureInvariant,
TimeSpan.FromMilliseconds(100));
private static int I32(string[] values, int index) =>
int.TryParse(Value(values, index), NumberStyles.Integer,
CultureInfo.InvariantCulture, out int parsed)
? parsed
: throw new FormatException("A VTClassic integer is invalid.");
private static uint U32(string[] values, int index) =>
uint.TryParse(Value(values, index), NumberStyles.Integer,
CultureInfo.InvariantCulture, out uint parsed)
? parsed
: throw new FormatException("A VTClassic key is invalid.");
private static double F64(string[] values, int index) =>
double.TryParse(Value(values, index).Replace(',', '.'),
NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed)
? parsed
: throw new FormatException("A VTClassic number is invalid.");
private static bool Bool(string[] values, int index) =>
bool.TryParse(Value(values, index), out bool parsed)
? parsed
: throw new FormatException("A VTClassic boolean is invalid.");
}

View file

@ -0,0 +1,742 @@
using System.Globalization;
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Reads and writes VTank's exact line-encoded <c>CondAct</c> Meta database.
/// The format is the public interchange contract used by legacy <c>.met</c>
/// profiles; it is deliberately independent from MossTank's native JSON store.
/// </summary>
internal static class VtankMetaProfileSerializer
{
private static readonly string[] Header =
[
"1", "CondAct", "5", "CType", "AType", "CData", "AData",
"State", "n", "n", "n", "n", "n",
];
private static readonly string[] TablePrefix = ["TABLE", "2", "k", "v", "n", "n"];
private static readonly string[] RecursiveTablePrefix = ["TABLE", "2", "K", "V", "n", "n"];
private const int MaximumRules = 100_000;
private const int MaximumNesting = 256;
public static bool TryLoad(string source, out MetaProfile profile, out string error)
{
try
{
var reader = new LineReader(source);
reader.Expect(Header);
int count = reader.ReadInt();
if (count is < 0 or > MaximumRules)
throw reader.Error("Invalid VTank Meta rule count.");
var parsed = new MetaProfile();
for (int index = 0; index < count; index++)
{
reader.Expect("i");
int conditionType = reader.ReadInt();
reader.Expect("i");
int actionType = reader.ReadInt();
MetaCondition condition = ReadCondition(reader, conditionType, 0);
MetaAction action = ReadAction(reader, actionType, 0);
reader.Expect("s");
parsed.Rules.Add(new MetaRule
{
State = reader.Read(),
Condition = condition,
Action = action,
Enabled = true,
});
}
reader.ExpectEnd();
profile = parsed;
error = string.Empty;
return true;
}
catch (Exception exception) when (exception is FormatException
or OverflowException or ArgumentOutOfRangeException)
{
profile = new MetaProfile();
error = exception.Message;
return false;
}
}
public static string Save(MetaProfile source)
{
ArgumentNullException.ThrowIfNull(source);
var writer = new LineWriter();
writer.Add(Header);
MetaRule[] rules = source.Rules.Where(static rule => rule.Enabled).ToArray();
writer.Add(rules.Length);
foreach (MetaRule rule in rules)
{
writer.Add("i", ConditionType(rule.Condition.Kind), "i", ActionType(rule.Action.Kind));
WriteCondition(writer, rule.Condition, 0);
WriteAction(writer, rule.Action, 0);
writer.Add("s", rule.State ?? string.Empty);
}
return writer.Finish();
}
private static MetaCondition ReadCondition(LineReader reader, int type, int depth)
{
CheckDepth(reader, depth);
var value = new MetaCondition { Kind = ConditionKind(type) };
switch (type)
{
case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20:
reader.Expect("i", "0");
break;
case 2 or 3:
reader.Expect(RecursiveTablePrefix);
ReadConditions(reader, value, reader.ReadCount(), depth);
break;
case 4:
reader.Expect("s");
value.Text = reader.Read();
break;
case 5 or 6 or 17 or 18 or 22 or 24:
reader.Expect("i");
value.Number = reader.ReadInt();
break;
case 11 or 12:
reader.Expect(TablePrefix, "2", "s", "n", "s");
value.Text = reader.Read();
reader.Expect("s", "c", "i");
value.Number = reader.ReadInt();
break;
case 13:
reader.Expect(TablePrefix, "3", "s", "n", "s");
value.Text = reader.Read();
reader.Expect("s", "c", "i");
value.Number = reader.ReadInt();
reader.Expect("s", "r", "d");
value.SecondaryNumber = reader.ReadDouble();
break;
case 14:
reader.Expect(TablePrefix, "3", "s", "p", "i");
value.TertiaryNumber = reader.ReadInt();
reader.Expect("s", "c", "i");
value.Number = reader.ReadInt();
reader.Expect("s", "r", "d");
value.SecondaryNumber = reader.ReadDouble();
break;
case 16:
reader.Expect(TablePrefix, "1", "s", "r", "d");
value.Number = reader.ReadDouble();
break;
case 21:
reader.Expect(RecursiveTablePrefix);
if (reader.ReadCount() != 1)
throw reader.Error("VTank Meta Not requires exactly one condition.");
reader.Expect("i");
value.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1));
break;
case 23:
reader.Expect(TablePrefix, "2", "s", "sid", "i");
value.Number = reader.ReadInt();
reader.Expect("s", "sec", "i");
value.SecondaryNumber = reader.ReadInt();
break;
case 25:
reader.Expect(TablePrefix, "1", "s", "dist", "d");
value.Number = reader.ReadDouble();
break;
case 26:
reader.Expect(TablePrefix, "1", "s", "e", "s");
value.Text = reader.Read();
break;
case 28:
reader.Expect(TablePrefix, "2", "s", "p", "s");
value.Text = reader.Read();
reader.Expect("s", "c", "s");
value.SecondaryText = reader.Read();
break;
default:
throw reader.Error($"Unknown VTank Meta condition type {type}.");
}
return value;
}
private static void ReadConditions(
LineReader reader,
MetaCondition target,
int count,
int depth)
{
for (int index = 0; index < count; index++)
{
reader.Expect("i");
target.Children.Add(ReadCondition(reader, reader.ReadInt(), depth + 1));
}
}
private static MetaAction ReadAction(LineReader reader, int type, int depth)
{
CheckDepth(reader, depth);
var value = new MetaAction { Kind = ActionKind(type) };
switch (type)
{
case 0 or 6:
reader.Expect("i", "0");
break;
case 1 or 2:
reader.Expect("s");
value.Text = reader.Read();
break;
case 3:
reader.Expect(RecursiveTablePrefix);
int count = reader.ReadCount();
for (int index = 0; index < count; index++)
{
reader.Expect("i");
value.Children.Add(ReadAction(reader, reader.ReadInt(), depth + 1));
}
break;
case 4:
ReadEmbeddedNavigation(reader, value);
break;
case 5:
reader.Expect(TablePrefix, "2", "s", "st", "s");
value.Text = reader.Read();
reader.Expect("s", "ret", "s");
value.SecondaryText = reader.Read();
break;
case 7 or 8:
reader.Expect(TablePrefix, "1", "s", "e", "s");
value.Text = reader.Read();
break;
case 9:
reader.Expect(TablePrefix, "3", "s", "s", "s");
value.Text = reader.Read();
reader.Expect("s", "r", "d");
value.Number = reader.ReadDouble();
reader.Expect("s", "t", "d");
value.SecondaryNumber = reader.ReadDouble();
break;
case 10 or 15:
reader.Expect(TablePrefix, "0");
break;
case 11:
reader.Expect(TablePrefix, "2", "s", "o", "s");
value.Text = reader.Read();
reader.Expect("s", "v", "s");
value.SecondaryText = reader.Read();
break;
case 12:
reader.Expect(TablePrefix, "2", "s", "o", "s");
value.Text = reader.Read();
reader.Expect("s", "v", "s");
value.SecondaryText = reader.Read();
break;
case 13:
reader.Expect(TablePrefix, "2", "s", "n", "s");
value.Text = reader.Read();
reader.Expect("s", "x", "ba");
int length = reader.ReadCount();
value.SecondaryText = reader.ReadByteArray(length);
break;
case 14:
reader.Expect(TablePrefix, "1", "s", "n", "s");
value.Text = reader.Read();
break;
default:
throw reader.Error($"Unknown VTank Meta action type {type}.");
}
return value;
}
private static void ReadEmbeddedNavigation(LineReader reader, MetaAction target)
{
reader.Expect("ba");
int serializedCharacters = reader.ReadCount();
target.SecondaryText = reader.Read();
int statedNodeCount = reader.ReadCount();
if (serializedCharacters <= 5)
{
target.Text = EmptyNavigation();
return;
}
var lines = new List<string> { reader.ReadExpected("uTank2 NAV 1.2") };
int mode = reader.ReadInt(out string modeLine);
lines.Add(modeLine);
int actualNodeCount;
if (mode == 3)
{
lines.Add(reader.Read());
lines.Add(reader.Read());
actualNodeCount = 1;
}
else if (mode is 1 or 2 or 4)
{
actualNodeCount = reader.ReadCount(out string countLine);
lines.Add(countLine);
for (int index = 0; index < actualNodeCount; index++)
ReadNavigationNode(reader, lines);
}
else
{
throw reader.Error($"Unknown embedded VTank navigation type {mode}.");
}
if (actualNodeCount != statedNodeCount)
throw reader.Error("Embedded VTank navigation node counts do not match.");
target.Text = string.Join("\r\n", lines) + "\r\n";
}
private static void ReadNavigationNode(LineReader reader, List<string> lines)
{
int type = reader.ReadInt(out string typeLine);
lines.Add(typeLine);
for (int index = 0; index < 4; index++)
lines.Add(reader.Read());
int extra = type switch
{
0 or 8 => 0,
1 or 2 or 3 or 4 => 1,
5 => 2,
6 or 7 => 6,
9 => 3,
_ => throw reader.Error($"Unknown embedded VTank waypoint type {type}."),
};
for (int index = 0; index < extra; index++)
lines.Add(reader.Read());
}
private static void WriteCondition(LineWriter writer, MetaCondition value, int depth)
{
CheckDepth(depth);
int type = ConditionType(value.Kind);
switch (type)
{
case 0 or 1 or 7 or 8 or 9 or 10 or 15 or 19 or 20:
writer.Add("i", "0");
break;
case 2 or 3:
writer.Add(RecursiveTablePrefix);
writer.Add(value.Children.Count);
foreach (MetaCondition child in value.Children)
{
writer.Add("i", ConditionType(child.Kind));
WriteCondition(writer, child, depth + 1);
}
break;
case 4:
writer.Add("s", value.Text);
break;
case 5 or 6 or 17 or 18 or 22 or 24:
writer.Add("i", IntValue(value.Number));
break;
case 11 or 12:
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
"s", "c", "i", IntValue(value.Number));
break;
case 13:
writer.Add(TablePrefix, "3", "s", "n", "s", value.Text,
"s", "c", "i", IntValue(value.Number),
"s", "r", "d", Number(value.SecondaryNumber));
break;
case 14:
writer.Add(TablePrefix, "3", "s", "p", "i", IntValue(value.TertiaryNumber),
"s", "c", "i", IntValue(value.Number),
"s", "r", "d", Number(value.SecondaryNumber));
break;
case 16:
writer.Add(TablePrefix, "1", "s", "r", "d", Number(value.Number));
break;
case 21:
if (value.Children.Count != 1)
throw new InvalidOperationException("VTank Meta Not requires exactly one condition.");
writer.Add(RecursiveTablePrefix, "1", "i", ConditionType(value.Children[0].Kind));
WriteCondition(writer, value.Children[0], depth + 1);
break;
case 23:
writer.Add(TablePrefix, "2", "s", "sid", "i", IntValue(value.Number),
"s", "sec", "i", IntValue(value.SecondaryNumber));
break;
case 25:
writer.Add(TablePrefix, "1", "s", "dist", "d", Number(value.Number));
break;
case 26:
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
break;
case 28:
writer.Add(TablePrefix, "2", "s", "p", "s", value.Text,
"s", "c", "s", value.SecondaryText);
break;
default:
throw new InvalidOperationException($"Unknown VTank Meta condition type {type}.");
}
}
private static void WriteAction(LineWriter writer, MetaAction value, int depth)
{
CheckDepth(depth);
int type = ActionType(value.Kind);
switch (type)
{
case 0 or 6:
writer.Add("i", "0");
break;
case 1 or 2:
writer.Add("s", value.Text);
break;
case 3:
writer.Add(RecursiveTablePrefix);
writer.Add(value.Children.Count);
foreach (MetaAction child in value.Children)
{
writer.Add("i", ActionType(child.Kind));
WriteAction(writer, child, depth + 1);
}
break;
case 4:
WriteEmbeddedNavigation(writer, value);
break;
case 5:
writer.Add(TablePrefix, "2", "s", "st", "s", value.Text,
"s", "ret", "s", value.SecondaryText);
break;
case 7 or 8:
writer.Add(TablePrefix, "1", "s", "e", "s", value.Text);
break;
case 9:
writer.Add(TablePrefix, "3", "s", "s", "s", value.Text,
"s", "r", "d", Number(value.Number),
"s", "t", "d", Number(value.SecondaryNumber));
break;
case 10 or 15:
writer.Add(TablePrefix, "0");
break;
case 11 or 12:
writer.Add(TablePrefix, "2", "s", "o", "s", value.Text,
"s", "v", "s", value.SecondaryText);
break;
case 13:
writer.Add(TablePrefix, "2", "s", "n", "s", value.Text,
"s", "x", "ba", value.SecondaryText.Length);
writer.AddBuggedByteArray(value.SecondaryText);
break;
case 14:
writer.Add(TablePrefix, "1", "s", "n", "s", value.Text);
break;
default:
throw new InvalidOperationException($"Unknown VTank Meta action type {type}.");
}
}
private static void WriteEmbeddedNavigation(LineWriter writer, MetaAction value)
{
string nav = string.IsNullOrWhiteSpace(value.Text) ? EmptyNavigation() : value.Text;
string normalized = NormalizeNewlines(nav);
string[] navLines = normalized.Split('\n', StringSplitOptions.None);
if (navLines.Length != 0 && navLines[^1].Length == 0)
navLines = navLines[..^1];
int nodes = NavigationNodeCount(navLines);
string name = string.IsNullOrEmpty(value.SecondaryText) ? "[None]" : value.SecondaryText;
int characters = name.Length + 2
+ nodes.ToString(CultureInfo.InvariantCulture).Length + 2
+ navLines.Sum(static line => line.Length + 2);
writer.Add("ba", characters, name, nodes);
writer.Add(navLines);
}
private static int NavigationNodeCount(string[] lines)
{
if (lines.Length < 2 || !lines[0].Equals("uTank2 NAV 1.2", StringComparison.Ordinal))
throw new InvalidOperationException("Embedded Meta route is not uTank2 NAV 1.2 data.");
int mode = int.Parse(lines[1], NumberStyles.Integer, CultureInfo.InvariantCulture);
if (mode == 3)
return 1;
if (mode is not (1 or 2 or 4) || lines.Length < 3)
throw new InvalidOperationException("Embedded Meta route has an invalid navigation type.");
return int.Parse(lines[2], NumberStyles.Integer, CultureInfo.InvariantCulture);
}
private static string EmptyNavigation() => "uTank2 NAV 1.2\r\n1\r\n0\r\n";
private static string NormalizeNewlines(string value) => value
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n');
private static int ConditionType(MetaConditionKind kind) => kind switch
{
MetaConditionKind.Never => 0,
MetaConditionKind.Always => 1,
MetaConditionKind.All => 2,
MetaConditionKind.Any => 3,
MetaConditionKind.ChatMessage => 4,
MetaConditionKind.PackSlotsLessThanOrEqual => 5,
MetaConditionKind.SecondsInStateGreaterThanOrEqual => 6,
MetaConditionKind.NavigationRouteEmpty => 7,
MetaConditionKind.CharacterDeath => 8,
MetaConditionKind.AnyVendorOpen => 9,
MetaConditionKind.VendorClosed => 10,
MetaConditionKind.InventoryItemCountLessThanOrEqual => 11,
MetaConditionKind.InventoryItemCountGreaterThanOrEqual => 12,
MetaConditionKind.MonsterNameCountWithinDistance => 13,
MetaConditionKind.MonsterPriorityCountWithinDistance => 14,
MetaConditionKind.NeedToBuff => 15,
MetaConditionKind.NoMonstersWithinDistance => 16,
MetaConditionKind.LandblockEquals => 17,
MetaConditionKind.LandcellEquals => 18,
MetaConditionKind.PortalspaceEntered => 19,
MetaConditionKind.PortalspaceExited => 20,
MetaConditionKind.Not => 21,
MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual => 22,
MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual => 23,
MetaConditionKind.BurdenPercentGreaterThanOrEqual => 24,
MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual => 25,
MetaConditionKind.Expression => 26,
MetaConditionKind.ChatMessageCapture => 28,
_ => throw new InvalidOperationException($"Unsupported Meta condition {kind}."),
};
private static MetaConditionKind ConditionKind(int type) => type switch
{
0 => MetaConditionKind.Never,
1 => MetaConditionKind.Always,
2 => MetaConditionKind.All,
3 => MetaConditionKind.Any,
4 => MetaConditionKind.ChatMessage,
5 => MetaConditionKind.PackSlotsLessThanOrEqual,
6 => MetaConditionKind.SecondsInStateGreaterThanOrEqual,
7 => MetaConditionKind.NavigationRouteEmpty,
8 => MetaConditionKind.CharacterDeath,
9 => MetaConditionKind.AnyVendorOpen,
10 => MetaConditionKind.VendorClosed,
11 => MetaConditionKind.InventoryItemCountLessThanOrEqual,
12 => MetaConditionKind.InventoryItemCountGreaterThanOrEqual,
13 => MetaConditionKind.MonsterNameCountWithinDistance,
14 => MetaConditionKind.MonsterPriorityCountWithinDistance,
15 => MetaConditionKind.NeedToBuff,
16 => MetaConditionKind.NoMonstersWithinDistance,
17 => MetaConditionKind.LandblockEquals,
18 => MetaConditionKind.LandcellEquals,
19 => MetaConditionKind.PortalspaceEntered,
20 => MetaConditionKind.PortalspaceExited,
21 => MetaConditionKind.Not,
22 => MetaConditionKind.PersistentSecondsInStateGreaterThanOrEqual,
23 => MetaConditionKind.TimeLeftOnSpellGreaterThanOrEqual,
24 => MetaConditionKind.BurdenPercentGreaterThanOrEqual,
25 => MetaConditionKind.DistanceFromAnyRoutePointGreaterThanOrEqual,
26 => MetaConditionKind.Expression,
28 => MetaConditionKind.ChatMessageCapture,
_ => throw new FormatException($"Unknown VTank Meta condition type {type}."),
};
private static int ActionType(MetaActionKind kind) => kind switch
{
MetaActionKind.None => 0,
MetaActionKind.SetMetaState => 1,
MetaActionKind.ChatCommand => 2,
MetaActionKind.All => 3,
MetaActionKind.LoadEmbeddedNavigationRoute => 4,
MetaActionKind.CallMetaState => 5,
MetaActionKind.ReturnFromCall => 6,
MetaActionKind.ExpressionAction => 7,
MetaActionKind.ChatExpression => 8,
MetaActionKind.SetWatchdog => 9,
MetaActionKind.ClearWatchdog => 10,
MetaActionKind.GetVtankOption => 11,
MetaActionKind.SetVtankOption => 12,
MetaActionKind.CreateView => 13,
MetaActionKind.DestroyView => 14,
MetaActionKind.DestroyAllViews => 15,
_ => throw new InvalidOperationException($"Unsupported Meta action {kind}."),
};
private static MetaActionKind ActionKind(int type) => type switch
{
0 => MetaActionKind.None,
1 => MetaActionKind.SetMetaState,
2 => MetaActionKind.ChatCommand,
3 => MetaActionKind.All,
4 => MetaActionKind.LoadEmbeddedNavigationRoute,
5 => MetaActionKind.CallMetaState,
6 => MetaActionKind.ReturnFromCall,
7 => MetaActionKind.ExpressionAction,
8 => MetaActionKind.ChatExpression,
9 => MetaActionKind.SetWatchdog,
10 => MetaActionKind.ClearWatchdog,
11 => MetaActionKind.GetVtankOption,
12 => MetaActionKind.SetVtankOption,
13 => MetaActionKind.CreateView,
14 => MetaActionKind.DestroyView,
15 => MetaActionKind.DestroyAllViews,
_ => throw new FormatException($"Unknown VTank Meta action type {type}."),
};
private static string Number(double value)
{
if (!double.IsFinite(value))
throw new InvalidOperationException("VTank Meta numbers must be finite.");
return value.ToString("R", CultureInfo.InvariantCulture);
}
private static int IntValue(double value)
{
if (!double.IsFinite(value) || value != Math.Truncate(value))
throw new InvalidOperationException("VTank Meta integer fields require whole numbers.");
return checked((int)value);
}
private static void CheckDepth(LineReader reader, int depth)
{
if (depth > MaximumNesting)
throw reader.Error("VTank Meta nesting is too deep.");
}
private static void CheckDepth(int depth)
{
if (depth > MaximumNesting)
throw new InvalidOperationException("VTank Meta nesting is too deep.");
}
private sealed class LineReader
{
private readonly List<string> _lines;
private int _index;
public LineReader(string source)
{
string normalized = NormalizeNewlines(source ?? string.Empty);
_lines = normalized.Split('\n', StringSplitOptions.None).ToList();
if (_lines.Count != 0 && _lines[^1].Length == 0)
_lines.RemoveAt(_lines.Count - 1);
}
public string Read()
{
if (_index >= _lines.Count)
throw Error("Unexpected end of VTank Meta data.");
return _lines[_index++];
}
public string ReadExpected(string expected)
{
string actual = Read();
if (!actual.Equals(expected, StringComparison.Ordinal))
throw Error($"Expected '{expected}', found '{actual}'.");
return actual;
}
public void Expect(params string[] expected)
{
foreach (string value in expected)
ReadExpected(value);
}
public void Expect(string[] first, params string[] rest)
{
Expect(first);
Expect(rest);
}
public int ReadInt() => int.Parse(
Read(), NumberStyles.Integer, CultureInfo.InvariantCulture);
public int ReadInt(out string line)
{
line = Read();
return int.Parse(line, NumberStyles.Integer, CultureInfo.InvariantCulture);
}
public int ReadCount()
{
int value = ReadInt();
if (value is < 0 or > MaximumRules)
throw Error("Invalid VTank Meta collection count.");
return value;
}
public int ReadCount(out string line)
{
int value = ReadInt(out line);
if (value is < 0 or > MaximumRules)
throw Error("Invalid VTank Meta collection count.");
return value;
}
public double ReadDouble() => double.Parse(
Read(), NumberStyles.Float, CultureInfo.InvariantCulture);
public string ReadByteArray(int length)
{
if (length < 0)
throw Error("Invalid VTank Meta byte-array length.");
string first = Read();
if (first.Length >= length)
{
string value = first[..length];
string remainder = first[length..];
if (remainder.Length != 0)
_lines.Insert(_index, remainder);
return value;
}
var valueBuilder = new System.Text.StringBuilder(first);
while (valueBuilder.Length < length && _index < _lines.Count)
{
valueBuilder.Append("\r\n");
valueBuilder.Append(Read());
}
if (valueBuilder.Length < length)
throw Error("Truncated VTank Meta byte array.");
string combined = valueBuilder.ToString();
string result = combined[..length];
string remaining = combined[length..];
if (remaining.Length != 0)
_lines.Insert(_index, remaining.TrimStart('\r', '\n'));
return result;
}
public void ExpectEnd()
{
while (_index < _lines.Count && _lines[_index].Length == 0)
_index++;
if (_index != _lines.Count)
throw Error($"Unexpected trailing VTank Meta data '{_lines[_index]}'.");
}
public FormatException Error(string message) =>
new($"VTank Meta line {Math.Min(_index + 1, _lines.Count + 1)}: {message}");
}
private sealed class LineWriter
{
private readonly List<string> _lines = [];
private readonly List<int> _buggedByteArrays = [];
public void Add(params object?[] values)
{
foreach (object? value in values)
_lines.Add(Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty);
}
public void Add(string[] first, params object?[] rest)
{
Add(first.Cast<object?>().ToArray());
Add(rest);
}
public void AddBuggedByteArray(string value)
{
_buggedByteArrays.Add(_lines.Count);
_lines.Add(value ?? string.Empty);
}
public string Finish()
{
foreach (int index in _buggedByteArrays.OrderDescending())
{
if (index + 1 >= _lines.Count)
throw new InvalidOperationException("CreateView cannot terminate a VTank Meta record.");
_lines[index] += _lines[index + 1];
_lines.RemoveAt(index + 1);
}
return string.Join("\r\n", _lines) + "\r\n";
}
}
}

View file

@ -0,0 +1,303 @@
using System.Globalization;
using AcDream.Plugin.Abstractions;
namespace AcDream.Plugins.MossTank;
/// <summary>Reader for VTank's verbatim <c>uTank2 NAV 1.2</c> format.</summary>
internal static class VtankNavRouteSerializer
{
private const string Header = "uTank2 NAV 1.2";
public static string Save(NavigationSettings source)
{
ArgumentNullException.ThrowIfNull(source);
var writer = new StringWriter(CultureInfo.InvariantCulture)
{
NewLine = "\r\n",
};
writer.WriteLine(Header);
writer.WriteLine(source.Mode switch
{
RouteMode.Circular => 1,
RouteMode.Linear => 2,
RouteMode.Target => 3,
RouteMode.Once => 4,
_ => throw new InvalidOperationException("Unknown navigation type."),
});
if (source.Mode == RouteMode.Target)
{
writer.WriteLine(source.FollowTargetName ?? string.Empty);
writer.WriteLine(unchecked((int)source.FollowTargetObjectId));
return writer.ToString();
}
writer.WriteLine(source.Waypoints.Count);
foreach (RouteWaypoint waypoint in source.Waypoints)
WriteWaypoint(writer, waypoint);
return writer.ToString();
}
public static bool TryLoad(
string source,
NavigationSettings target,
ISpellCatalog spells,
out string error)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(spells);
try
{
string nav = UnwrapEmbedded(source);
using var reader = new StringReader(nav);
if (!ReadLine(reader).Equals(Header, StringComparison.Ordinal))
throw new FormatException("Nav file version does not match uTank2 NAV 1.2.");
var parsed = new NavigationSettings
{
Enabled = target.Enabled,
Priority = target.Priority,
MinimumDistanceMeters = target.MinimumDistanceMeters,
FollowAroundCorners = target.FollowAroundCorners,
OpenDoors = target.OpenDoors,
Mode = ReadInt(reader) switch
{
1 => RouteMode.Circular,
2 => RouteMode.Linear,
3 => RouteMode.Target,
4 => RouteMode.Once,
_ => throw new FormatException("Unknown VTank navigation type."),
},
};
if (parsed.Mode == RouteMode.Target)
{
parsed.FollowTargetName = ReadLine(reader);
parsed.FollowTargetObjectId = unchecked((uint)ReadInt(reader));
}
else
{
int count = ReadInt(reader);
if (count is < 0 or > 100_000)
throw new FormatException("Invalid VTank waypoint count.");
for (int index = 0; index < count; index++)
parsed.Waypoints.Add(ReadWaypoint(reader, spells));
}
Apply(parsed, target);
error = string.Empty;
return true;
}
catch (Exception exception) when (exception is FormatException
or OverflowException or EndOfStreamException)
{
error = exception.Message;
return false;
}
}
private static RouteWaypoint ReadWaypoint(
TextReader reader,
ISpellCatalog spells)
{
int type = ReadInt(reader);
double eastWest = ReadDouble(reader);
double northSouth = ReadDouble(reader);
double elevation = ReadDouble(reader);
_ = ReadLine(reader); // historical unused coordinate component
var waypoint = new RouteWaypoint
{
Type = type switch
{
0 => RouteWaypointType.Point,
1 => RouteWaypointType.Portal,
2 => RouteWaypointType.Recall,
3 => RouteWaypointType.Pause,
4 => RouteWaypointType.ChatCommand,
5 => RouteWaypointType.OpenVendor,
6 => RouteWaypointType.PortalByName,
7 => RouteWaypointType.UseNpc,
8 => RouteWaypointType.Checkpoint,
9 => RouteWaypointType.Jump,
_ => throw new FormatException($"Unknown VTank waypoint type {type}."),
},
Position = Position(eastWest, northSouth, elevation),
};
switch (type)
{
case 1:
waypoint.ObjectId = unchecked((uint)ReadInt(reader));
break;
case 2:
waypoint.RecallSpellId = checked((uint)ReadInt(reader));
if (spells.TryGet(waypoint.RecallSpellId, out PluginSpellInfo spell))
waypoint.RecallSpellName = spell.Name;
break;
case 3:
waypoint.DurationMilliseconds = ReadInt(reader);
break;
case 4:
waypoint.Text = ReadLine(reader);
break;
case 5:
waypoint.ObjectId = unchecked((uint)ReadInt(reader));
waypoint.ObjectName = ReadLine(reader);
break;
case 6:
case 7:
waypoint.ObjectName = ReadLine(reader);
waypoint.LegacyObjectClass = ReadInt(reader);
waypoint.LegacyReferenceValid = ReadBoolean(reader);
double referenceEastWest = ReadDouble(reader);
double referenceNorthSouth = ReadDouble(reader);
double referenceElevation = ReadDouble(reader);
waypoint.Position = Position(
referenceEastWest,
referenceNorthSouth,
referenceElevation);
break;
case 9:
waypoint.JumpHeadingDegrees = checked((float)ReadDouble(reader));
waypoint.JumpRun = ReadBoolean(reader);
ParseJump(ReadLine(reader), waypoint);
break;
}
return waypoint;
}
private static void WriteWaypoint(TextWriter writer, RouteWaypoint waypoint)
{
int type = (int)waypoint.Type;
writer.WriteLine(type.ToString(CultureInfo.InvariantCulture));
WriteDouble(writer, waypoint.Position.EastWest);
WriteDouble(writer, waypoint.Position.NorthSouth);
WriteDouble(writer, waypoint.Position.Elevation);
writer.WriteLine("0");
switch (waypoint.Type)
{
case RouteWaypointType.Point:
case RouteWaypointType.Checkpoint:
break;
case RouteWaypointType.Portal:
writer.WriteLine(unchecked((int)waypoint.ObjectId)
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Recall:
writer.WriteLine(waypoint.RecallSpellId
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.Pause:
writer.WriteLine(waypoint.DurationMilliseconds
.ToString(CultureInfo.InvariantCulture));
break;
case RouteWaypointType.ChatCommand:
writer.WriteLine(waypoint.Text ?? string.Empty);
break;
case RouteWaypointType.OpenVendor:
writer.WriteLine(unchecked((int)waypoint.ObjectId)
.ToString(CultureInfo.InvariantCulture));
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
break;
case RouteWaypointType.PortalByName:
case RouteWaypointType.UseNpc:
writer.WriteLine(waypoint.ObjectName ?? string.Empty);
int objectClass = waypoint.LegacyObjectClass != 0
? waypoint.LegacyObjectClass
: waypoint.Type == RouteWaypointType.PortalByName ? 14 : 37;
writer.WriteLine(objectClass.ToString(CultureInfo.InvariantCulture));
writer.WriteLine(waypoint.LegacyReferenceValid
.ToString(CultureInfo.InvariantCulture));
WriteDouble(writer, waypoint.Position.EastWest);
WriteDouble(writer, waypoint.Position.NorthSouth);
WriteDouble(writer, waypoint.Position.Elevation);
break;
case RouteWaypointType.Jump:
WriteDouble(writer, waypoint.JumpHeadingDegrees);
writer.WriteLine(waypoint.JumpRun.ToString(CultureInfo.InvariantCulture));
string suffix = waypoint.JumpDirection switch
{
RouteJumpDirection.StrafeLeft => "4",
RouteJumpDirection.StrafeRight => "5",
_ => "3",
};
writer.WriteLine(
waypoint.JumpChargeMilliseconds.ToString(
"0.0000",
CultureInfo.InvariantCulture)
+ suffix);
break;
default:
throw new InvalidOperationException(
$"Unknown waypoint type {waypoint.Type}.");
}
}
private static void WriteDouble(TextWriter writer, double value) =>
writer.WriteLine(Convert.ToString(value, CultureInfo.InvariantCulture));
private static void ParseJump(string source, RouteWaypoint target)
{
string value = source.Trim();
char suffix = value.Length == 0 ? '\0' : value[^1];
bool encoded = suffix is '3' or '4' or '5'
&& value.Length >= 6
&& value[^6] == '.';
string milliseconds = encoded ? value[..^1] : value;
target.JumpChargeMilliseconds = checked((int)Math.Round(
double.Parse(milliseconds, NumberStyles.Float, CultureInfo.InvariantCulture),
MidpointRounding.AwayFromZero));
target.JumpDirection = suffix switch
{
'4' when encoded => RouteJumpDirection.StrafeLeft,
'5' when encoded => RouteJumpDirection.StrafeRight,
_ => RouteJumpDirection.Forward,
};
}
private static string UnwrapEmbedded(string source)
{
string normalized = source?.Replace("\r\n", "\n", StringComparison.Ordinal)
?? string.Empty;
if (normalized.StartsWith(Header, StringComparison.Ordinal))
return normalized;
using var reader = new StringReader(normalized);
_ = ReadLine(reader); // embedded route display name
_ = ReadInt(reader); // embedded point count
return reader.ReadToEnd();
}
private static PluginNavigationPosition Position(
double eastWest,
double northSouth,
double elevation) => new(
0u,
eastWest,
northSouth,
elevation,
0f,
IsOutdoor: true);
private static void Apply(NavigationSettings source, NavigationSettings target)
{
target.Mode = source.Mode;
target.FollowTargetObjectId = source.FollowTargetObjectId;
target.FollowTargetName = source.FollowTargetName;
target.Waypoints.Clear();
target.Waypoints.AddRange(source.Waypoints.Select(static value => value.Clone()));
}
private static string ReadLine(TextReader reader) =>
reader.ReadLine() ?? throw new EndOfStreamException("Unexpected end of VTank nav data.");
private static int ReadInt(TextReader reader) => int.Parse(
ReadLine(reader),
NumberStyles.Integer,
CultureInfo.InvariantCulture);
private static double ReadDouble(TextReader reader) => double.Parse(
ReadLine(reader),
NumberStyles.Float,
CultureInfo.InvariantCulture);
private static bool ReadBoolean(TextReader reader) => bool.Parse(ReadLine(reader));
}

View file

@ -0,0 +1,221 @@
namespace AcDream.Plugins.MossTank;
/// <summary>
/// Exact 137-row Settings table from VTank's shipped
/// <c>uTank2.Resources.defaultsettings.usd</c>. Order is retained because
/// <c>/vt opt list</c> presents the database order four entries per line.
/// </summary>
internal static class VtankOptionCatalog
{
internal static readonly string[] Names =
[
"EnableLooting", "EnableNav", "EnableBuffing", "EnableCombat",
"SpellDiffExcessThreshold-Hunt", "SpellDiffExcessThreshold-Buff",
"ArrowheadFletchDiffExcessThreshold", "Recharge-Norm-HitP",
"Recharge-Norm-Stam", "Recharge-Norm-Mana", "Recharge-NoTarg-HitP",
"Recharge-NoTarg-Stam", "Recharge-NoTarg-Mana", "Recharge-Helper-HitP",
"Recharge-Helper-Stam", "Recharge-Helper-Mana", "DoHelp",
"AttackDistance", "AttackMinimumDistance", "ApproachDistance",
"RingDistance", "CorpseApproachRange-Max", "CorpseApproachRange-Min",
"NavCloseStopRange", "NavFarStopRange", "UsePortalDistance",
"HelperDistanceHitP", "HelperDistanceStam", "HelperDistanceMana",
"MinimumRingTargets", "DefaultMeleeAttackHeight", "CastDispelSelf",
"UseDispelItems", "AutoCram", "AutoStack", "ReadUnknownScrolls",
"UseDispelDrum", "SwitchWandsToDebuff", "AutoCraftItems",
"UseHealersHeart", "JumpOutWandCasting", "LootAllCorpses",
"LootFellowCorpses", "DoJiggle", "RandomHelperBuffs",
"RandomHelperIntervalSeconds", "IdlePeaceMode", "TargetLock",
"StopMacroOnDeath", "UseArcs", "ArcRange", "TargetSelectMethod",
"TargetSelectAngleRange", "IdleBuffTopoff", "IdleBuffTopoffTimeSeconds",
"RebuffTimeRemainingSeconds", "RefillWornMana",
"RefillWornMana-Item-ManaPercent", "BuffProfile-Prots",
"BuffProfile-Banes", "BuffProfile_Prots", "BuffProfile_Banes",
"DebuffEachFirst", "AutoAttackPower", "LootPriorityBoost",
"CorpseCacheTimeoutMinutes", "CorpseItemAppearanceTimeoutSeconds",
"CorpseItemIDTimeoutSeconds", "DebuffSelectionMethod",
"ManaStoneLootCount", "ManaTankMinimumMana", "SplitPeas",
"SpellCompMin-Critical", "SpellCompMin-Normal", "SpellCompMin-Idle",
"RechargeBoostTimeSeconds", "RechargeBoostAmount", "UseSpecialAmmo",
"OpenDoors", "DoorIDRange", "DoorOpenRange",
"DoorLockpickDiffExcessThreshold", "ManaChargesWhenOff",
"AutoFellowManagement", "MinimumHealKitSuccessChance",
"UseKitsInMagicMode", "StaminaToHealthMultiplier",
"ManaToHealthMultiplier", "NavPriorityBoost", "DeleteGhostMonsters",
"GhostMonsterSpellAttemptCount", "WhoYouGonnaCall",
"BlacklistMonsterAttemptCount", "BlacklistMonsterTimeoutSeconds",
"CombineSalvage", "LootOnlyRareCorpses",
"DeleteGhostMonstersByHPTracker", "GhostDeleteHPTrackerSeconds",
"GoToPeaceModeToUseKits", "UseRecklessness", "DebuffPrecastSeconds",
"ClearLevelBoostFlagOnCast", "IdleCraftCount_HealthKits",
"IdleCraftCount_StamKits", "IdleCraftCount_ManaKits",
"IdleCraftCount_HealthFood", "IdleCraftCount_StamFood",
"IdleCraftCount_ManaFood", "BuffCastRecast_Seconds",
"BuffCastRecastReset_Seconds", "EnableMeta", "BlacklistedSpellComps",
"DropToPeaceModeRetryCount", "FollowAroundCorners",
"BlacklistCorpseOpenAttemptCount", "BlacklistCorpseOpenTimeoutSeconds",
"SummonPets", "PetRangeMode", "PetCustomRange", "PetRefillCount-Idle",
"PetRefillCount-Normal", "CorpseOpenTimeoutSeconds",
"PetMonsterDensity", "CorpseLootItemMaxAttempts", "FastCastBuffs",
"UseBreakableTurnTo", "UseProjectileAwareness",
"CollisionProjectileRadius", "CollisionStepDistance",
"ShowCollisionDebug", "MaximumCollisionChecksPerTick", "SpellRangeFudge",
"BuffWithUntrained-Item", "BuffWithUntrained-Creature",
"BuffWithUntrained-Life", "AllowDebuffFallback", "RechargeHandlerSet",
];
// Scalar defaults are read verbatim from VTank's shipped Settings table.
// RechargeHandlerSet is the one non-scalar row and is represented by its
// table identity; the Vitals policy owns its live ordered handlers.
private static readonly IReadOnlyDictionary<string, MonsterValue> Defaults =
new Dictionary<string, MonsterValue>(StringComparer.OrdinalIgnoreCase)
{
["EnableLooting"] = MonsterValue.FromBoolean(false),
["EnableNav"] = MonsterValue.FromBoolean(false),
["EnableBuffing"] = MonsterValue.FromBoolean(true),
["EnableCombat"] = MonsterValue.FromBoolean(true),
["SpellDiffExcessThreshold-Hunt"] = MonsterValue.FromNumber(25d),
["SpellDiffExcessThreshold-Buff"] = MonsterValue.FromNumber(5d),
["ArrowheadFletchDiffExcessThreshold"] = MonsterValue.FromNumber(10d),
["Recharge-Norm-HitP"] = MonsterValue.FromNumber(75d),
["Recharge-Norm-Stam"] = MonsterValue.FromNumber(50d),
["Recharge-Norm-Mana"] = MonsterValue.FromNumber(50d),
["Recharge-NoTarg-HitP"] = MonsterValue.FromNumber(1d),
["Recharge-NoTarg-Stam"] = MonsterValue.FromNumber(1d),
["Recharge-NoTarg-Mana"] = MonsterValue.FromNumber(1d),
["Recharge-Helper-HitP"] = MonsterValue.FromNumber(20d),
["Recharge-Helper-Stam"] = MonsterValue.FromNumber(1d),
["Recharge-Helper-Mana"] = MonsterValue.FromNumber(1d),
["DoHelp"] = MonsterValue.FromBoolean(true),
["AttackDistance"] = MonsterValue.FromNumber(0.0208333333333333d),
["AttackMinimumDistance"] = MonsterValue.FromNumber(0d),
["ApproachDistance"] = MonsterValue.FromNumber(0d),
["RingDistance"] = MonsterValue.FromNumber(0.0208333333333333d),
["CorpseApproachRange-Max"] = MonsterValue.FromNumber(0d),
["CorpseApproachRange-Min"] = MonsterValue.FromNumber(0.014d),
["NavCloseStopRange"] = MonsterValue.FromNumber(0.00833333333333333d),
["NavFarStopRange"] = MonsterValue.FromNumber(999999d),
["UsePortalDistance"] = MonsterValue.FromNumber(0.0166666666666667d),
["HelperDistanceHitP"] = MonsterValue.FromNumber(0.310416666666667d),
["HelperDistanceStam"] = MonsterValue.FromNumber(0.310416666666667d),
["HelperDistanceMana"] = MonsterValue.FromNumber(0.166666666666667d),
["MinimumRingTargets"] = MonsterValue.FromNumber(4d),
["DefaultMeleeAttackHeight"] = MonsterValue.FromNumber(2d),
["CastDispelSelf"] = MonsterValue.FromBoolean(false),
["UseDispelItems"] = MonsterValue.FromBoolean(false),
["AutoCram"] = MonsterValue.FromBoolean(false),
["AutoStack"] = MonsterValue.FromBoolean(true),
["ReadUnknownScrolls"] = MonsterValue.FromBoolean(true),
["UseDispelDrum"] = MonsterValue.FromBoolean(false),
["SwitchWandsToDebuff"] = MonsterValue.FromBoolean(false),
["AutoCraftItems"] = MonsterValue.FromBoolean(true),
["UseHealersHeart"] = MonsterValue.FromBoolean(true),
["JumpOutWandCasting"] = MonsterValue.FromBoolean(false),
["LootAllCorpses"] = MonsterValue.FromBoolean(false),
["LootFellowCorpses"] = MonsterValue.FromBoolean(false),
["DoJiggle"] = MonsterValue.FromBoolean(false),
["RandomHelperBuffs"] = MonsterValue.FromBoolean(false),
["RandomHelperIntervalSeconds"] = MonsterValue.FromNumber(5d),
["IdlePeaceMode"] = MonsterValue.FromBoolean(false),
["TargetLock"] = MonsterValue.FromBoolean(false),
["StopMacroOnDeath"] = MonsterValue.FromBoolean(true),
["UseArcs"] = MonsterValue.FromNumber(1d),
["ArcRange"] = MonsterValue.FromNumber(0.0208333333333333d),
["TargetSelectMethod"] = MonsterValue.FromNumber(3d),
["TargetSelectAngleRange"] = MonsterValue.FromNumber(0.0208333333333333d),
["IdleBuffTopoff"] = MonsterValue.FromBoolean(false),
["IdleBuffTopoffTimeSeconds"] = MonsterValue.FromNumber(1200d),
["RebuffTimeRemainingSeconds"] = MonsterValue.FromNumber(300d),
["RefillWornMana"] = MonsterValue.FromBoolean(true),
["RefillWornMana-Item-ManaPercent"] = MonsterValue.FromNumber(33d),
["BuffProfile-Prots"] = MonsterValue.FromText("ALFCBPS"),
["BuffProfile-Banes"] = MonsterValue.FromText("ALFCBPS"),
["BuffProfile_Prots"] = MonsterValue.FromNumber(2d),
["BuffProfile_Banes"] = MonsterValue.FromNumber(2d),
["DebuffEachFirst"] = MonsterValue.FromNumber(1d),
["AutoAttackPower"] = MonsterValue.FromBoolean(true),
["LootPriorityBoost"] = MonsterValue.FromBoolean(false),
["CorpseCacheTimeoutMinutes"] = MonsterValue.FromNumber(60d),
["CorpseItemAppearanceTimeoutSeconds"] = MonsterValue.FromNumber(6d),
["CorpseItemIDTimeoutSeconds"] = MonsterValue.FromNumber(60d),
["DebuffSelectionMethod"] = MonsterValue.FromNumber(2d),
["ManaStoneLootCount"] = MonsterValue.FromNumber(4d),
["ManaTankMinimumMana"] = MonsterValue.FromNumber(1000d),
["SplitPeas"] = MonsterValue.FromBoolean(true),
["SpellCompMin-Critical"] = MonsterValue.FromNumber(4d),
["SpellCompMin-Normal"] = MonsterValue.FromNumber(20d),
["SpellCompMin-Idle"] = MonsterValue.FromNumber(20d),
["RechargeBoostTimeSeconds"] = MonsterValue.FromNumber(5d),
["RechargeBoostAmount"] = MonsterValue.FromNumber(40d),
["UseSpecialAmmo"] = MonsterValue.FromNumber(0d),
["OpenDoors"] = MonsterValue.FromBoolean(false),
["DoorIDRange"] = MonsterValue.FromNumber(0.0833333333333333d),
["DoorOpenRange"] = MonsterValue.FromNumber(0.0166666666666667d),
["DoorLockpickDiffExcessThreshold"] = MonsterValue.FromNumber(-50d),
["ManaChargesWhenOff"] = MonsterValue.FromBoolean(true),
["AutoFellowManagement"] = MonsterValue.FromBoolean(true),
["MinimumHealKitSuccessChance"] = MonsterValue.FromNumber(95d),
["UseKitsInMagicMode"] = MonsterValue.FromBoolean(true),
["StaminaToHealthMultiplier"] = MonsterValue.FromNumber(1.9d),
["ManaToHealthMultiplier"] = MonsterValue.FromNumber(2.8d),
["NavPriorityBoost"] = MonsterValue.FromBoolean(false),
["DeleteGhostMonsters"] = MonsterValue.FromBoolean(true),
["GhostMonsterSpellAttemptCount"] = MonsterValue.FromNumber(200d),
["WhoYouGonnaCall"] = MonsterValue.FromBoolean(true),
["BlacklistMonsterAttemptCount"] = MonsterValue.FromNumber(4d),
["BlacklistMonsterTimeoutSeconds"] = MonsterValue.FromNumber(120d),
["CombineSalvage"] = MonsterValue.FromBoolean(true),
["LootOnlyRareCorpses"] = MonsterValue.FromBoolean(false),
["DeleteGhostMonstersByHPTracker"] = MonsterValue.FromBoolean(true),
["GhostDeleteHPTrackerSeconds"] = MonsterValue.FromNumber(30d),
["GoToPeaceModeToUseKits"] = MonsterValue.FromBoolean(false),
["UseRecklessness"] = MonsterValue.FromBoolean(true),
["DebuffPrecastSeconds"] = MonsterValue.FromNumber(5d),
["ClearLevelBoostFlagOnCast"] = MonsterValue.FromBoolean(true),
["IdleCraftCount_HealthKits"] = MonsterValue.FromNumber(2d),
["IdleCraftCount_StamKits"] = MonsterValue.FromNumber(2d),
["IdleCraftCount_ManaKits"] = MonsterValue.FromNumber(2d),
["IdleCraftCount_HealthFood"] = MonsterValue.FromNumber(15d),
["IdleCraftCount_StamFood"] = MonsterValue.FromNumber(15d),
["IdleCraftCount_ManaFood"] = MonsterValue.FromNumber(15d),
["BuffCastRecast_Seconds"] = MonsterValue.FromNumber(30d),
["BuffCastRecastReset_Seconds"] = MonsterValue.FromNumber(30d),
["EnableMeta"] = MonsterValue.FromBoolean(false),
["BlacklistedSpellComps"] = MonsterValue.FromText(string.Empty),
["DropToPeaceModeRetryCount"] = MonsterValue.FromNumber(34d),
["FollowAroundCorners"] = MonsterValue.FromBoolean(true),
["BlacklistCorpseOpenAttemptCount"] = MonsterValue.FromNumber(30d),
["BlacklistCorpseOpenTimeoutSeconds"] = MonsterValue.FromNumber(200d),
["SummonPets"] = MonsterValue.FromBoolean(true),
["PetRangeMode"] = MonsterValue.FromNumber(0d),
["PetCustomRange"] = MonsterValue.FromNumber(0.0208333333333333d),
["PetRefillCount-Idle"] = MonsterValue.FromNumber(3d),
["PetRefillCount-Normal"] = MonsterValue.FromNumber(1d),
["CorpseOpenTimeoutSeconds"] = MonsterValue.FromNumber(1.5d),
["PetMonsterDensity"] = MonsterValue.FromNumber(1d),
["CorpseLootItemMaxAttempts"] = MonsterValue.FromNumber(20d),
["FastCastBuffs"] = MonsterValue.FromBoolean(false),
["UseBreakableTurnTo"] = MonsterValue.FromBoolean(true),
["UseProjectileAwareness"] = MonsterValue.FromBoolean(true),
["CollisionProjectileRadius"] = MonsterValue.FromNumber(0.4d),
["CollisionStepDistance"] = MonsterValue.FromNumber(0.7d),
["ShowCollisionDebug"] = MonsterValue.FromBoolean(false),
["MaximumCollisionChecksPerTick"] = MonsterValue.FromNumber(500d),
["SpellRangeFudge"] = MonsterValue.FromNumber(1d),
["BuffWithUntrained-Item"] = MonsterValue.FromNumber(80d),
["BuffWithUntrained-Creature"] = MonsterValue.FromNumber(80d),
["BuffWithUntrained-Life"] = MonsterValue.FromNumber(80d),
["AllowDebuffFallback"] = MonsterValue.FromBoolean(false),
["RechargeHandlerSet"] = MonsterValue.FromText("RechargeHandlerSet"),
};
internal static bool IsKnown(string name) =>
Names.Contains(name, StringComparer.OrdinalIgnoreCase);
internal static string Canonical(string name) =>
Names.First(value => value.Equals(name, StringComparison.OrdinalIgnoreCase));
internal static MonsterValue Default(string name) =>
Defaults.TryGetValue(name, out MonsterValue value)
? value
: MonsterValue.FromNumber(0d);
}

View file

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MossTank's settings view. A second registered panel rather than a tab
control: two panels with complementary visible bindings need no new markup
vocabulary, and the toggle is just another Action.
Adjuster buttons rather than typed fields, because editable text in a
plugin panel needs keyboard routing plumbed through first. -->
<panel x="40" y="120" w="360" h="432" title="MossTank — Settings" visible="{SettingsVisible}">
<label x="12" y="30" text="{DifficultyText}" color="#FFE8E4C8" />
<button x="292" y="26" w="26" h="22" text="-" onclick="{DifficultyDown}" />
<button x="322" y="26" w="26" h="22" text="+" onclick="{DifficultyUp}" />
<label x="12" y="58" text="{RebuffText}" color="#FFE8E4C8" />
<button x="292" y="54" w="26" h="22" text="-" onclick="{RebuffDown}" />
<button x="322" y="54" w="26" h="22" text="+" onclick="{RebuffUp}" />
<label x="12" y="86" text="{ManaFloorText}" color="#FFB9C7A0" />
<button x="292" y="82" w="26" h="22" text="-" onclick="{ManaFloorDown}" />
<button x="322" y="82" w="26" h="22" text="+" onclick="{ManaFloorUp}" />
<label x="12" y="114" text="{ManaTargetText}" color="#FFB9C7A0" />
<button x="292" y="110" w="26" h="22" text="-" onclick="{ManaTargetDown}" />
<button x="322" y="110" w="26" h="22" text="+" onclick="{ManaTargetUp}" />
<label x="12" y="142" text="{StaminaFloorText}" color="#FFB9C7A0" />
<button x="292" y="138" w="26" h="22" text="-" onclick="{StaminaFloorDown}" />
<button x="322" y="138" w="26" h="22" text="+" onclick="{StaminaFloorUp}" />
<label x="12" y="172" text="{VitalUpkeepText}" color="#FF8F9C78" />
<button x="292" y="168" w="56" h="22" text="toggle" onclick="{ToggleVitalUpkeep}" />
<label x="12" y="200" text="{AttributesText}" color="#FF8F9C78" />
<button x="292" y="196" w="56" h="22" text="toggle" onclick="{ToggleAttributes}" />
<label x="12" y="228" text="{TrainedOnlyText}" color="#FF8F9C78" />
<button x="292" y="224" w="56" h="22" text="toggle" onclick="{ToggleTrainedOnly}" />
<label x="12" y="256" text="{ProtectionsText}" color="#FF8F9C78" />
<button x="292" y="252" w="56" h="22" text="toggle" onclick="{ToggleProtections}" />
<label x="12" y="284" text="{AurasText}" color="#FF8F9C78" />
<button x="292" y="280" w="56" h="22" text="toggle" onclick="{ToggleAuras}" />
<label x="12" y="312" text="{BanesText}" color="#FF8F9C78" />
<button x="292" y="308" w="56" h="22" text="toggle" onclick="{ToggleBanes}" />
<label x="12" y="340" text="{RegenerationText}" color="#FF8F9C78" />
<button x="292" y="336" w="56" h="22" text="toggle" onclick="{ToggleRegeneration}" />
<label x="12" y="368" text="{OtherText}" color="#FF8F9C78" />
<button x="292" y="364" w="56" h="22" text="toggle" onclick="{ToggleOther}" />
<button x="12" y="396" w="108" h="26" text="Back" onclick="{CloseSettings}" />
</panel>

View file

@ -1,11 +1,586 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MossTank's main panel. Bindings resolve by property name against
MossTankPanel: {Buff}/{OpenSettings} are Actions, {MainVisible} hides the
panel at character select and while the settings view is open. -->
<panel x="40" y="120" w="360" h="132" title="MossTank" visible="{MainVisible}">
<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}" />
<button x="128" y="94" w="108" h="28" text="Settings" onclick="{OpenSettings}" />
<!--
VTank-compatible MossTank shell. Every enabled control below is backed by
live policy. Every visible control is bound to the behavior exercised by the
corresponding VTank compatibility lane.
-->
<panel x="28" y="42" w="800" h="244" title="MossTank v0.1.0"
visible="{WindowAvailable}" resize="none">
<!-- Virindi Tank tab order and compact text-strip presentation. -->
<tab x="8" y="22" w="58" h="18" text="Options"
selected="{OptionsSelected}" enabled="{OptionsTabEnabled}" onclick="{ShowOptions}" />
<tab x="66" y="22" w="56" h="18" text="Profiles"
selected="{ProfilesSelected}" enabled="{ProfilesTabEnabled}" onclick="{ShowProfiles}" />
<tab x="122" y="22" w="42" h="18" text="Vitals"
selected="{VitalsSelected}" enabled="{VitalsTabEnabled}" onclick="{ShowVitals}" />
<tab x="164" y="22" w="62" h="18" text="Monsters"
selected="{MonstersSelected}" enabled="{MonstersTabEnabled}" onclick="{ShowMonsters}" />
<tab x="226" y="22" w="42" h="18" text="Items"
selected="{ItemsSelected}" enabled="{ItemsTabEnabled}" onclick="{ShowItems}" />
<tab x="268" y="22" w="86" h="18" text="Consumables"
selected="{ConsumablesSelected}" enabled="{ConsumablesTabEnabled}" onclick="{ShowConsumables}" />
<tab x="354" y="22" w="42" h="18" text="Buffs"
selected="{BuffsSelected}" enabled="{BuffsTabEnabled}" onclick="{ShowBuffs}" />
<tab x="396" y="22" w="44" h="18" text="Route"
selected="{RouteSelected}" enabled="{RouteTabEnabled}" onclick="{ShowRoute}" />
<tab x="440" y="22" w="38" h="18" text="Meta"
selected="{MetaSelected}" enabled="{MetaTabEnabled}" onclick="{ShowMeta}" />
<!-- Options: VTank's compact four-column Options view. -->
<group x="8" y="42" w="784" h="194" visible="{OptionsVisible}">
<label x="4" y="5" text="Monster Range:" color="#FFE8DEC3" />
<field x="92" y="1" w="40" h="21" text="{MonsterRangeValueText}"
onchange="{SetMonsterRangeText}" maxlength="6" background="#E6000000"
tooltip="Maximum monster acquisition range in meters." />
<button x="140" y="1" w="98" h="22" text="Force Buff" onclick="{ForceBuff}" />
<label x="4" y="31" text="Ring Range:" color="#FFE8DEC3" />
<field x="92" y="27" w="40" h="21" text="{RingRangeValueText}"
onchange="{SetRingRangeText}" maxlength="6" background="#E6000000"
tooltip="Maximum range for ring-spell target density." />
<button x="140" y="27" w="98" h="22" text="Cancel Force Buff"
onclick="{CancelForceBuff}" />
<label x="4" y="57" text="Approach:" color="#FFE8DEC3" />
<field x="92" y="53" w="40" h="21" text="{ApproachRangeValueText}"
onchange="{SetApproachRangeText}" maxlength="6" background="#E6000000"
tooltip="Distance at which MossTank approaches its combat target." />
<label x="4" y="83" text="Follow/Nav Min:" color="#FFE8DEC3" />
<field x="92" y="79" w="40" h="21" text="{FollowNavMinimumValueText}"
onchange="{SetFollowNavMinimumText}" maxlength="6" background="#E6000000"
tooltip="Minimum distance maintained while following or navigating." />
<toggle x="246" y="2" w="166" h="20" text="Enable Buffing"
checked="{BuffingEnabled}" onclick="{ToggleBuffing}" />
<toggle x="246" y="25" w="166" h="20" text="Enable AutoCram"
checked="{AutoCramEnabled}" onclick="{ToggleAutoCram}" />
<toggle x="246" y="48" w="166" h="20" text="Use Dispel Items"
checked="{UseDispelItemsEnabled}" onclick="{ToggleUseDispelItems}" />
<toggle x="246" y="71" w="166" h="20" text="Cast Dispel Self"
checked="{CastDispelSelfEnabled}" onclick="{ToggleCastDispelSelf}" />
<toggle x="246" y="94" w="166" h="20" text="Enable Combat"
checked="{CombatEnabled}" onclick="{ToggleCombatEnabled}" />
<toggle x="246" y="117" w="166" h="20" text="Enable Navigation"
checked="{NavigationEnabled}" onclick="{ToggleNavigation}" />
<toggle x="246" y="140" w="166" h="20" text="Enable Looting"
checked="{LootEnabled}" onclick="{ToggleLooting}" />
<toggle x="246" y="163" w="166" h="20" text="Enable Meta"
checked="{MetaEnabled}" onclick="{ToggleMeta}" />
<toggle x="414" y="2" w="176" h="20" text="Auto Follow Mgmt."
checked="{AutoFellowManagementEnabled}" onclick="{ToggleAutoFellowManagement}" />
<toggle x="414" y="25" w="176" h="20" text="M. Charges When Off"
checked="{ManaChargesWhenOffEnabled}" onclick="{ToggleManaChargesWhenOff}" />
<toggle x="414" y="48" w="176" h="20" text="Boost Nav. Priority"
checked="{NavigationPriorityEnabled}" onclick="{ToggleNavigationPriority}" />
<toggle x="414" y="71" w="176" h="20" text="Boost Loot Priority"
checked="{LootPriorityBoostEnabled}" onclick="{ToggleLootPriorityBoost}" />
<toggle x="414" y="94" w="176" h="20" text="Loot Only Rare Corpses"
checked="{LootOnlyRareCorpsesEnabled}" onclick="{ToggleLootOnlyRareCorpses}" />
<toggle x="414" y="117" w="176" h="20" text="Peace Mode When Idle"
checked="{IdlePeaceModeEnabled}" onclick="{ToggleIdlePeaceMode}" />
<toggle x="414" y="140" w="176" h="20" text="Rebuff When Idle"
checked="{IdleBuffTopoffEnabled}" onclick="{ToggleIdleBuffTopoff}" />
<toggle x="594" y="2" w="150" h="20" text="Summon Pets"
checked="{SummonPetsEnabled}" onclick="{ToggleSummonPets}" />
<toggle x="594" y="28" w="142" h="20" text="Custom Pet Range"
checked="{CustomPetRangeEnabled}" onclick="{TogglePetRangeMode}" />
<field x="738" y="27" w="40" h="21" text="{PetCustomRangeValueText}"
onchange="{SetPetCustomRangeText}" maxlength="6" background="#E6000000"
tooltip="Custom pet summon range in meters." />
<label x="594" y="57" text="Pet Min. Monsters:" color="#FFE8DEC3" />
<field x="738" y="53" w="40" h="21" text="{PetDensityValueText}"
onchange="{SetPetDensityText}" maxlength="3" background="#E6000000"
tooltip="Minimum nearby monsters required before summoning a pet." />
<button x="594" y="94" w="184" h="23" text="Advanced Options"
onclick="{ShowAdvancedOptions}" />
<button x="594" y="126" w="184" h="28" text="{CombatButtonText}"
onclick="{ToggleCombat}" background="#E6263D1D" border="#FF80661E" />
<label x="594" y="164" text="{CombatStatus}" color="#FFC7B98F" />
</group>
<group x="8" y="42" w="784" h="194" visible="{AdvancedOptionsVisible}">
<label x="4" y="2" text="Advanced Options — complete VTank settings table"
color="#FFE8DEC3" />
<list x="4" y="24" w="338" h="138" rowheight="17"
items="{AdvancedOptionNames}" selected="{SelectedAdvancedOptionIndex}"
onchange="{SelectAdvancedOption}"
tooltip="Select one of Virindi Tank's 137 advanced options." />
<label x="356" y="28" text="{AdvancedOptionName}" color="#FFE8DEC3" />
<field x="356" y="52" w="250" h="23" text="{AdvancedOptionValueDraft}"
onchange="{SetAdvancedOptionValueDraft}" onsubmit="{SubmitAdvancedOption}"
maxlength="160" clearonsubmit="false" background="#E6000000"
tooltip="Edit the selected advanced-option value and press Enter or Apply." />
<button x="616" y="52" w="80" h="23" text="Apply"
onclick="{ApplyAdvancedOption}" />
<button x="704" y="52" w="72" h="23" text="Back"
onclick="{HideAdvancedOptions}" />
<label x="356" y="84" text="{AdvancedOptionNotice}" color="#FFC7B98F" />
</group>
<!-- Profiles: durable VTank macro profiles. By char is a distinct document
for each character; named copies are shared and immediately hot-loaded. -->
<group x="8" y="42" w="784" h="194" visible="{ProfilesVisible}">
<label x="4" y="8" text="Macro Settings" color="#FFE8DEC3" />
<menu x="112" y="3" w="154" h="22" items="{MacroProfileNames}"
selected="{SelectedMacroProfile}" onchange="{SelectMacroProfile}"
rows="7" openupward="false" tooltip="Select the active macro profile." />
<label x="4" y="42" text="Profile name:" color="#FFE8DEC3" />
<field x="112" y="36" w="154" h="23" text="{ProfileNameDraft}"
onchange="{SetProfileNameDraft}" onsubmit="{CreateNamedProfile}"
maxlength="64" clearonsubmit="false" background="#E6000000"
color="#FFE8DEC3" tooltip="Name a new or copied macro profile." />
<button x="278" y="36" w="82" h="23" text="CopyTo"
onclick="{CopyProfile}" />
<button x="370" y="36" w="68" h="23" text="New"
onclick="{CreateProfile}" />
<button x="448" y="36" w="112" h="23" text="Clear profile!"
onclick="{ClearProfile}" />
<toggle x="580" y="4" w="120" h="20" text="Mine only"
checked="{MineOnlyEnabled}" onclick="{ToggleMineOnly}" />
<toggle x="4" y="66" w="176" h="20" text="Enable Auto-Navigator"
checked="{NavigationEnabled}" onclick="{ToggleNavigation}" />
<menu x="188" y="64" w="120" h="22" items="{RouteProfileNames}"
selected="{SelectedRouteProfile}" onchange="{SelectRouteProfile}"
rows="7" openupward="false" tooltip="Select the active navigation profile." />
<field x="316" y="64" w="112" h="22" text="{RouteProfileNameDraft}"
onchange="{SetRouteProfileNameDraft}"
onsubmit="{CreateNamedRouteProfile}" maxlength="64"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Name a new or copied navigation profile." />
<button x="436" y="64" w="64" h="22" text="CopyTo"
onclick="{CopyRouteProfile}" />
<button x="506" y="64" w="48" h="22" text="New"
onclick="{CreateRouteProfile}" />
<button x="560" y="64" w="84" h="22" text="Clear route"
onclick="{ClearRouteProfile}" />
<toggle x="4" y="92" w="150" h="20" text="Enable Looting"
checked="{LootEnabled}" onclick="{ToggleLooting}" />
<menu x="156" y="90" w="116" h="22" items="{LootProfileNames}"
selected="{LootProfileName}" onchange="{SelectLootProfile}"
rows="7" openupward="false" tooltip="Select the active loot profile." />
<button x="280" y="90" w="94" h="23" text="Show Editor"
onclick="{ShowLootEditor}" />
<toggle x="384" y="92" w="166" h="20" text="Loot Priority Boost"
checked="{LootPriorityBoostEnabled}"
onclick="{ToggleLootPriorityBoost}" />
<label x="556" y="96" text="{LootStatus}" color="#FF9B9072" />
<label x="4" y="122" text="Loot engine" color="#FFC7B98F" />
<menu x="88" y="116" w="240" h="22" items="{LootClassifierNames}"
selected="{SelectedLootClassifier}" onchange="{SelectLootClassifier}"
rows="7" openupward="false" tooltip="Select the loot-rule engine for this profile." />
<label x="4" y="146" text="Enable Meta Actions [Meta profile follows]"
color="#FF6F6857" />
<label x="4" y="174" text="{ProfileLifecycleNotice}"
color="#FFC7B98F" />
</group>
<!-- Vitals: VTank's exact nine Recharge-* thresholds. -->
<group x="8" y="42" w="784" h="194" visible="{VitalsVisible}">
<label x="4" y="4" text="Heal at:" color="#FFE8DEC3" />
<slider x="92" y="4" w="150" h="15" value="{NormalHealthValue}" onchange="{SetNormalHealth}"
tooltip="Combat health recharge threshold." />
<label x="250" y="4" text="{NormalHealthText}" color="#FFC7B98F" />
<label x="4" y="24" text="Restam at:" color="#FFE8DEC3" />
<slider x="92" y="24" w="150" h="15" value="{NormalStaminaValue}" onchange="{SetNormalStamina}"
tooltip="Combat stamina recharge threshold." />
<label x="250" y="24" text="{NormalStaminaText}" color="#FFC7B98F" />
<label x="4" y="44" text="Get Mana at:" color="#FFE8DEC3" />
<slider x="92" y="44" w="150" h="15" value="{NormalManaValue}" onchange="{SetNormalMana}"
tooltip="Combat mana recharge threshold." />
<label x="250" y="44" text="{NormalManaText}" color="#FFC7B98F" />
<label x="4" y="64" text="Top-off HP:" color="#FFE8DEC3" />
<slider x="92" y="64" w="150" h="15" value="{NoTargetHealthValue}" onchange="{SetNoTargetHealth}"
tooltip="Idle health top-off threshold." />
<label x="250" y="64" text="{NoTargetHealthText}" color="#FFC7B98F" />
<label x="4" y="84" text="Top-off Stam:" color="#FFE8DEC3" />
<slider x="92" y="84" w="150" h="15" value="{NoTargetStaminaValue}" onchange="{SetNoTargetStamina}"
tooltip="Idle stamina top-off threshold." />
<label x="250" y="84" text="{NoTargetStaminaText}" color="#FFC7B98F" />
<label x="4" y="104" text="Top-off Mana:" color="#FFE8DEC3" />
<slider x="92" y="104" w="150" h="15" value="{NoTargetManaValue}" onchange="{SetNoTargetMana}"
tooltip="Idle mana top-off threshold." />
<label x="250" y="104" text="{NoTargetManaText}" color="#FFC7B98F" />
<label x="4" y="124" text="Heal others:" color="#FFE8DEC3" />
<slider x="92" y="124" w="150" h="15" value="{HelperHealthValue}" onchange="{SetHelperHealth}"
tooltip="Fellowship healing threshold." />
<label x="250" y="124" text="{HelperHealthText}" color="#FFC7B98F" />
<label x="4" y="144" text="Restam others:" color="#FFE8DEC3" />
<slider x="92" y="144" w="150" h="15" value="{HelperStaminaValue}" onchange="{SetHelperStamina}"
tooltip="Fellowship stamina-restoration threshold." />
<label x="250" y="144" text="{HelperStaminaText}" color="#FFC7B98F" />
<label x="4" y="164" text="Infuse others:" color="#FFE8DEC3" />
<slider x="92" y="164" w="150" h="15" value="{HelperManaValue}" onchange="{SetHelperMana}"
tooltip="Fellowship mana-infusion threshold." />
<label x="250" y="164" text="{HelperManaText}" color="#FFC7B98F" />
<toggle x="350" y="4" w="190" h="20" text="Enable vital recharge"
checked="{VitalUpkeepEnabled}" onclick="{ToggleVitalUpkeep}" />
<toggle x="350" y="28" w="190" h="20" text="Help fellows"
checked="{HelpOthersEnabled}" onclick="{ToggleHelpOthers}" />
<label x="350" y="60" text="{VitalStatus}" color="#FFC7B98F" />
<label x="350" y="88" text="{Vitals}" color="#FFE8DEC3" />
</group>
<!-- Monsters: VTank's ordered expression/action table. -->
<group x="8" y="42" w="784" h="194" visible="{MonstersVisible}">
<label x="4" y="0" text="F B G I Y V A R S W FC Cp DC Cs Name / expression P Dmg type"
color="#FFE8DEC3" />
<list x="4" y="16" w="776" h="62" rowheight="15"
items="{MonsterRows}" selected="{SelectedMonsterRuleIndex}"
onchange="{SelectMonsterRule}" tooltip="Select an ordered monster rule to edit." />
<field x="4" y="82" w="210" h="21" text="{MonsterExpressionDraft}"
onchange="{SetMonsterExpressionDraft}" onsubmit="{ApplyMonsterExpression}"
maxlength="256" background="#E6000000" color="#FFE8DEC3"
tooltip="Monster name or UtilityBelt-compatible match expression." />
<button x="220" y="82" w="52" h="21" text="Apply" onclick="{ApplyMonsterRule}" />
<button x="278" y="82" w="46" h="21" text="Add" onclick="{AddMonsterRule}" />
<button x="330" y="82" w="62" h="21" text="Add Sel" onclick="{AddSelectedMonster}" />
<button x="398" y="82" w="62" h="21" text="Remove" onclick="{RemoveMonsterRule}" />
<button x="466" y="82" w="28" h="21" text="↑" onclick="{MoveMonsterRuleUp}"
tooltip="Move the selected monster rule up." />
<button x="500" y="82" w="28" h="21" text="↓" onclick="{MoveMonsterRuleDown}"
tooltip="Move the selected monster rule down." />
<label x="538" y="86" text="{MonsterPriorityText}" color="#FFC7B98F" />
<button x="632" y="82" w="28" h="21" text="-" onclick="{MonsterPriorityDown}"
tooltip="Decrease the selected monster rule priority." />
<button x="666" y="82" w="28" h="21" text="+" onclick="{MonsterPriorityUp}"
tooltip="Increase the selected monster rule priority." />
<label x="4" y="110" text="Dmg" color="#FFE8DEC3" />
<menu x="38" y="106" w="92" h="20" items="{DamageTypeNames}"
selected="{SelectedDamageType}" onchange="{SelectMonsterDamage}"
rows="8" openupward="true" tooltip="Choose the attack damage type for this rule." />
<label x="140" y="110" text="Ex Vuln" color="#FFE8DEC3" />
<menu x="194" y="106" w="92" h="20" items="{ExtraVulnerabilityNames}"
selected="{SelectedExtraVulnerability}"
onchange="{SelectMonsterExtraVulnerability}" rows="8" openupward="true"
tooltip="Choose an extra vulnerability element for this rule." />
<label x="296" y="110" text="Pet" color="#FFE8DEC3" />
<menu x="324" y="106" w="92" h="20" items="{PetDamageTypeNames}"
selected="{SelectedPetDamage}" onchange="{SelectMonsterPetDamage}"
rows="8" openupward="true" tooltip="Choose the summoned-pet damage type for this rule." />
<label x="432" y="110" text="{MonsterEditorNotice}" color="#FF9B9072" />
<toggle x="4" y="132" w="48" h="18" text="F" checked="{MonsterFester}" onclick="{ToggleMonsterFester}" tooltip="Fester Other" />
<toggle x="56" y="132" w="48" h="18" text="B" checked="{MonsterBroadside}" onclick="{ToggleMonsterBroadside}" tooltip="Broadside of a Tumerok" />
<toggle x="108" y="132" w="48" h="18" text="G" checked="{MonsterGravityWell}" onclick="{ToggleMonsterGravityWell}" tooltip="Gravity Well" />
<toggle x="160" y="132" w="48" h="18" text="I" checked="{MonsterImperil}" onclick="{ToggleMonsterImperil}" tooltip="Imperil Other" />
<toggle x="212" y="132" w="48" h="18" text="Y" checked="{MonsterYield}" onclick="{ToggleMonsterYield}" tooltip="Yield Other" />
<toggle x="264" y="132" w="48" h="18" text="V" checked="{MonsterVulnerability}" onclick="{ToggleMonsterVulnerability}" tooltip="Elemental Vulnerability Other" />
<toggle x="316" y="132" w="48" h="18" text="A" checked="{MonsterAttack}" onclick="{ToggleMonsterAttack}" tooltip="Attack this monster" />
<toggle x="368" y="132" w="48" h="18" text="R" checked="{MonsterRing}" onclick="{ToggleMonsterRing}" tooltip="Allow ring spells" />
<toggle x="420" y="132" w="48" h="18" text="S" checked="{MonsterStreak}" onclick="{ToggleMonsterStreak}" tooltip="Allow streak spells" />
<toggle x="472" y="132" w="48" h="18" text="W" checked="{MonsterWeakening}" onclick="{ToggleMonsterWeakening}" tooltip="Weakening Curse" />
<toggle x="524" y="132" w="48" h="18" text="FC" checked="{MonsterFestering}" onclick="{ToggleMonsterFestering}" tooltip="Festering Curse" />
<toggle x="576" y="132" w="48" h="18" text="Cp" checked="{MonsterCorruption}" onclick="{ToggleMonsterCorruption}" tooltip="Corruption" />
<toggle x="628" y="132" w="48" h="18" text="DC" checked="{MonsterDestructive}" onclick="{ToggleMonsterDestructive}" tooltip="Destructive Curse" />
<toggle x="680" y="132" w="48" h="18" text="Cs" checked="{MonsterCorrosion}" onclick="{ToggleMonsterCorrosion}" tooltip="Corrosion" />
<label x="4" y="160" text="{MonsterEquipmentText}" color="#FFC7B98F" />
<button x="390" y="155" w="116" h="22" text="Weapon ← selected" onclick="{SetMonsterWeapon}" />
<button x="512" y="155" w="116" h="22" text="Offhand ← selected" onclick="{SetMonsterOffhand}" />
<button x="634" y="155" w="94" h="22" text="Clear equip" onclick="{ClearMonsterEquipment}" />
</group>
<!-- Items: exact-name profile membership drives weapons, wands and pets. -->
<group x="8" y="42" w="784" h="194" visible="{ItemsVisible}">
<label x="4" y="8" text="Weapons / Wands / Shields / Pets" color="#FFE8DEC3" />
<label x="438" y="8" text="Hands" color="#FFE8DEC3" />
<list x="4" y="28" w="414" h="112" rowheight="18"
items="{ItemRows}" selected="{SelectedItemRowIndex}"
onchange="{SelectItemRow}" tooltip="Select a profiled weapon, wand, shield, or pet device." />
<button x="4" y="154" w="126" h="25" text="Add" onclick="{AddSelectedItem}" />
<button x="138" y="154" w="138" h="25" text="Add (no buffs)"
onclick="{AddSelectedItemNoBuffs}" />
<button x="284" y="154" w="126" h="25" text="Remove"
onclick="{RemoveSelectedItem}" />
<label x="438" y="36" text="{MonsterEquipmentText}" color="#FFC7B98F" />
<toggle x="438" y="64" w="220" h="20" text="Refill Worn Mana"
checked="{RefillWornManaEnabled}" onclick="{ToggleRefillWornMana}" />
<slider x="438" y="90" w="188" h="15" value="{RefillWornManaValue}"
onchange="{SetRefillWornMana}" tooltip="Worn-item mana refill threshold." />
<label x="636" y="88" text="{RefillWornManaText}" color="#FFC7B98F" />
<label x="438" y="116" text="{ItemManaRechargeStatus}" color="#FF9B9072" />
<label x="438" y="160" text="{ProfileNotice}" color="#FF9B9072" />
</group>
<!-- Consumables: the same exact-name profile VTank consults for phials. -->
<group x="8" y="42" w="784" h="194" visible="{ConsumablesVisible}">
<label x="4" y="8"
text="Gems / Food / Kits / Potions / Charges / Grenades / Lockpicks"
color="#FFE8DEC3" />
<label x="438" y="8" text="Excluded Scarab Types:" color="#FFE8DEC3" />
<list x="4" y="28" w="414" h="112" rowheight="18"
items="{ConsumableRows}" selected="{SelectedConsumableRowIndex}"
onchange="{SelectConsumableRow}" tooltip="Select a profiled consumable to remove." />
<button x="4" y="154" w="176" h="25" text="Add"
onclick="{AddSelectedConsumable}" />
<button x="188" y="154" w="110" h="25" text="Add All Peas"
onclick="{AddAllPeas}" />
<button x="306" y="154" w="104" h="25" text="Remove"
onclick="{RemoveSelectedConsumable}" />
<label x="438" y="160" text="{ProfileNotice}" color="#FF9B9072" />
</group>
<!-- Buffs: every visible switch maps to the live buff plan. -->
<group x="8" y="42" w="784" h="194" visible="{BuffsVisible}">
<toggle x="4" y="4" w="220" h="20" text="Trained skills only"
checked="{TrainedOnlyEnabled}" onclick="{ToggleTrainedOnly}" />
<toggle x="4" y="28" w="220" h="20" text="Attributes"
checked="{AttributesEnabled}" onclick="{ToggleAttributes}" />
<toggle x="4" y="52" w="220" h="20" text="Protections"
checked="{ProtectionsEnabled}" onclick="{ToggleProtections}" />
<toggle x="4" y="76" w="220" h="20" text="Weapon auras"
checked="{AurasEnabled}" onclick="{ToggleAuras}" />
<toggle x="244" y="4" w="220" h="20" text="Armor banes"
checked="{BanesEnabled}" onclick="{ToggleBanes}" />
<toggle x="244" y="28" w="220" h="20" text="Regeneration"
checked="{RegenerationEnabled}" onclick="{ToggleRegeneration}" />
<toggle x="244" y="52" w="220" h="20" text="Other self-spells"
checked="{OtherEnabled}" onclick="{ToggleOther}" />
<label x="486" y="4" text="{DifficultyText}" color="#FFE8DEC3" />
<button x="670" y="0" w="30" h="23" text="-" onclick="{DifficultyDown}"
tooltip="Decrease the skill-over-difficulty margin." />
<button x="706" y="0" w="30" h="23" text="+" onclick="{DifficultyUp}"
tooltip="Increase the skill-over-difficulty margin." />
<label x="486" y="34" text="{RebuffText}" color="#FFE8DEC3" />
<button x="670" y="28" w="30" h="23" text="-" onclick="{RebuffDown}"
tooltip="Rebuff later." />
<button x="706" y="28" w="30" h="23" text="+" onclick="{RebuffUp}"
tooltip="Rebuff earlier." />
<button x="4" y="124" w="140" h="26" text="{BuffButtonText}" onclick="{Buff}" />
<label x="164" y="130" text="{BuffStatus}" color="#FFC7B98F" />
<label x="4" y="160" text="{Coverage}" color="#FF9B9072" />
</group>
<!-- Route: VTank's navigation-waypoint editor and execution surface. -->
<group x="8" y="42" w="784" h="194" visible="{RouteVisible}">
<label x="4" y="0" text="Navigation Waypoints" color="#FFE8DEC3" />
<list x="4" y="18" w="366" h="120" rowheight="17"
items="{RouteRows}" selected="{SelectedRouteWaypointIndex}"
onchange="{SelectRouteWaypoint}" tooltip="Select an ordered navigation waypoint." />
<button x="380" y="18" w="92" h="21" text="Add"
onclick="{AddRoutePoint}" />
<button x="478" y="18" w="116" h="21" text="Open Vendor"
onclick="{AddRouteOpenVendor}" />
<button x="600" y="18" w="116" h="21" text="Add Portal/NPC"
onclick="{AddRoutePortal}" />
<button x="380" y="45" w="92" h="21" text="Add Recall"
onclick="{AddRouteRecall}" />
<menu x="478" y="45" w="150" h="21" items="{RouteRecallNames}"
selected="{SelectedRouteRecall}" onchange="{SelectRouteRecall}"
rows="4" openupward="false" tooltip="Choose the recall type added by Add Recall." />
<toggle x="636" y="47" w="142" h="20" text="Follow Corners"
checked="{FollowAroundCornersEnabled}"
onclick="{ToggleFollowAroundCorners}" />
<toggle x="636" y="74" w="142" h="20" text="Open Doors"
checked="{OpenDoorsEnabled}" onclick="{ToggleOpenDoors}" />
<button x="380" y="72" w="92" h="21" text="Add Pause"
onclick="{AddRoutePause}" />
<label x="478" y="76" text="{RoutePauseText}" color="#FFC7B98F" />
<button x="570" y="72" w="28" h="21" text="-"
onclick="{RoutePauseDown}" tooltip="Decrease the pause duration." />
<button x="604" y="72" w="28" h="21" text="+"
onclick="{RoutePauseUp}" tooltip="Increase the pause duration." />
<button x="380" y="99" w="92" h="21" text="Add Chat"
onclick="{AddRouteChat}" />
<field x="478" y="99" w="150" h="21" text="{RouteChatDraft}"
onchange="{SetRouteChatDraft}" onsubmit="{SetRouteChatDraft}"
maxlength="128" clearonsubmit="false" background="#E6000000"
color="#FFE8DEC3" tooltip="Chat command added by Add Chat." />
<button x="636" y="99" w="80" h="21" text="Use NPC"
onclick="{AddRouteUseSelected}" />
<button x="380" y="126" w="92" h="21" text="Checkpoint"
onclick="{AddRouteCheckpoint}" />
<button x="478" y="126" w="72" h="21" text="Jump"
onclick="{AddRouteJump}" />
<button x="556" y="126" w="72" h="21" text="Remove"
onclick="{RemoveRouteWaypoint}" />
<button x="636" y="126" w="28" h="21" text="↑"
onclick="{MoveRouteWaypointUp}" tooltip="Move the selected waypoint up." />
<button x="670" y="126" w="28" h="21" text="↓"
onclick="{MoveRouteWaypointDown}" tooltip="Move the selected waypoint down." />
<menu x="4" y="148" w="90" h="22" items="{RouteModeNames}"
selected="{SelectedRouteMode}" onchange="{SelectRouteMode}"
rows="4" openupward="true" tooltip="Choose Circular, Linear, Target, or Once route behavior." />
<toggle x="102" y="150" w="132" h="20" text="Nav Priority"
checked="{NavigationPriorityEnabled}"
onclick="{ToggleNavigationPriority}" />
<button x="242" y="148" w="136" h="22" text="{RouteAddPositionText}"
onclick="{ToggleRouteAddPosition}" />
<button x="386" y="148" w="112" h="22" text="Set Follow Target"
onclick="{SetFollowTarget}" />
<label x="506" y="152" text="{RouteFollowTargetText}"
color="#FFC7B98F" />
<label x="4" y="174" text="{RouteMinimumDistanceText}"
color="#FFC7B98F" />
<button x="196" y="170" w="28" h="21" text="-"
onclick="{RouteMinimumDistanceDown}" tooltip="Decrease the route arrival distance." />
<button x="230" y="170" w="28" h="21" text="+"
onclick="{RouteMinimumDistanceUp}" tooltip="Increase the route arrival distance." />
<label x="270" y="174" text="{NavigationStatus}" color="#FF9B9072" />
<label x="520" y="174" text="{RouteNotice}" color="#FF9B9072" />
</group>
<!-- Meta: ordered VTank state-machine rules. Rules fire once per state
entry; transitions/call/return use the live MetaEngine. -->
<group x="8" y="42" w="784" h="194" visible="{MetaVisible}">
<menu x="4" y="0" w="132" h="21" items="{MetaProfileNames}"
selected="{SelectedMetaProfile}" onchange="{SelectMetaProfile}"
rows="7" openupward="false" tooltip="Select the active meta profile." />
<field x="142" y="0" w="116" h="21" text="{MetaProfileNameDraft}"
onchange="{SetMetaProfileNameDraft}"
onsubmit="{CreateNamedMetaProfile}" maxlength="64"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Name a new or copied meta profile." />
<button x="264" y="0" w="44" h="21" text="New"
onclick="{CreateMetaProfile}" />
<button x="314" y="0" w="58" h="21" text="CopyTo"
onclick="{CopyMetaProfile}" />
<button x="378" y="0" w="48" h="21" text="Clear"
onclick="{ClearMetaProfile}" />
<toggle x="446" y="2" w="112" h="20" text="Enable Meta"
checked="{MetaEnabled}" onclick="{ToggleMeta}" />
<label x="566" y="4" text="{MetaStateText}" color="#FFE8DEC3" />
<list x="4" y="26" w="776" h="68" rowheight="17"
items="{MetaRows}" selected="{SelectedMetaRuleIndex}"
onchange="{SelectMetaRule}" tooltip="Select an ordered meta rule to edit." />
<field x="4" y="100" w="118" h="21" text="{MetaStateDraft}"
onchange="{SetMetaStateDraft}" onsubmit="{SetMetaStateDraft}"
maxlength="64" clearonsubmit="false" background="#E6000000"
color="#FFE8DEC3" tooltip="State in which this meta rule is evaluated." />
<menu x="128" y="100" w="244" h="21" items="{MetaConditionNames}"
selected="{SelectedMetaCondition}" onchange="{SelectMetaCondition}"
rows="12" openupward="true" tooltip="Choose the rule condition." />
<menu x="378" y="100" w="204" h="21" items="{MetaActionNames}"
selected="{SelectedMetaAction}" onchange="{SelectMetaAction}"
rows="10" openupward="true" tooltip="Choose the action performed when the condition matches." />
<field x="4" y="126" w="244" h="21" text="{MetaConditionTextDraft}"
onchange="{SetMetaConditionTextDraft}"
onsubmit="{SetMetaConditionTextDraft}" maxlength="256"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Condition text or UtilityBelt-compatible expression." />
<field x="254" y="126" w="244" h="21" text="{MetaActionTextDraft}"
onchange="{SetMetaActionTextDraft}"
onsubmit="{SetMetaActionTextDraft}" maxlength="256"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Primary action argument or expression." />
<field x="504" y="126" w="160" h="21" text="{MetaSecondaryTextDraft}"
onchange="{SetMetaSecondaryTextDraft}"
onsubmit="{SetMetaSecondaryTextDraft}" maxlength="128"
clearonsubmit="false" background="#E6000000" color="#FFE8DEC3"
tooltip="Secondary action argument." />
<label x="4" y="156" text="{MetaNumberLabel}" color="#FFC7B98F" />
<button x="70" y="151" w="28" h="21" text="-" onclick="{MetaNumberDown}"
tooltip="Decrease the primary numeric argument." />
<button x="104" y="151" w="28" h="21" text="+" onclick="{MetaNumberUp}"
tooltip="Increase the primary numeric argument." />
<label x="144" y="156" text="{MetaSecondaryNumberLabel}"
color="#FFC7B98F" />
<button x="224" y="151" w="28" h="21" text="-"
onclick="{MetaSecondaryNumberDown}" tooltip="Decrease the secondary numeric argument." />
<button x="258" y="151" w="28" h="21" text="+"
onclick="{MetaSecondaryNumberUp}" tooltip="Increase the secondary numeric argument." />
<button x="306" y="151" w="52" h="21" text="Apply"
onclick="{ApplyMetaRule}" />
<button x="364" y="151" w="44" h="21" text="Add"
onclick="{AddMetaRule}" />
<button x="414" y="151" w="62" h="21" text="Remove"
onclick="{RemoveMetaRule}" />
<button x="482" y="151" w="28" h="21" text="↑"
onclick="{MoveMetaRuleUp}" tooltip="Move the selected meta rule up." />
<button x="516" y="151" w="28" h="21" text="↓"
onclick="{MoveMetaRuleDown}" tooltip="Move the selected meta rule down." />
<label x="558" y="156" text="{MetaStatus}" color="#FFC7B98F" />
<label x="4" y="178" text="{MetaNotice}" color="#FF9B9072" />
</group>
<!-- VTank loot-profile editor. Rules are first-match ordered; raw retail
properties can be addressed as int[105], float[...], string[...]. -->
<group x="8" y="42" w="784" h="194" visible="{LootEditorVisible}">
<label x="4" y="2" text="Loot profile rules (first match wins)"
color="#FFE8DEC3" />
<field x="224" y="0" w="132" h="21" text="{LootProfileNameDraft}"
onchange="{SetLootProfileNameDraft}"
onsubmit="{CreateNamedLootProfile}" maxlength="64"
background="#E6000000" color="#FFE8DEC3"
tooltip="Name a new or copied loot profile." />
<button x="364" y="0" w="52" h="21" text="New"
onclick="{CreateLootProfile}" />
<button x="424" y="0" w="62" h="21" text="CopyTo"
onclick="{CopyLootProfile}" />
<button x="494" y="0" w="58" h="21" text="Clear"
onclick="{ClearLootProfile}" />
<button x="684" y="0" w="92" h="21" text="Back"
onclick="{CloseLootEditor}" />
<list x="4" y="24" w="520" h="82" rowheight="17"
items="{LootRuleRows}" selected="{SelectedLootRuleIndex}"
onchange="{SelectLootRule}" tooltip="Select an ordered loot rule to edit." />
<menu x="536" y="24" w="146" h="21" items="{LootActionNames}"
selected="{SelectedLootAction}" onchange="{SelectLootAction}"
rows="9" openupward="false" tooltip="Choose the action for matching loot." />
<label x="536" y="52" text="{LootPriorityText}" color="#FFC7B98F" />
<button x="650" y="48" w="28" h="21" text="-"
onclick="{LootPriorityDown}" tooltip="Decrease loot rule priority." />
<button x="684" y="48" w="28" h="21" text="+"
onclick="{LootPriorityUp}" tooltip="Increase loot rule priority." />
<label x="536" y="80" text="{LootKeepCountText}" color="#FFC7B98F" />
<button x="650" y="76" w="28" h="21" text="-"
onclick="{LootKeepCountDown}" tooltip="Decrease the KeepUpTo count." />
<button x="684" y="76" w="28" h="21" text="+"
onclick="{LootKeepCountUp}" tooltip="Increase the KeepUpTo count." />
<field x="4" y="112" w="360" h="22" text="{LootExpressionDraft}"
onchange="{SetLootExpressionDraft}" onsubmit="{ApplyLootExpression}"
maxlength="256" background="#E6000000" color="#FFE8DEC3"
tooltip="VTClassic or UtilityBelt-compatible loot match expression." />
<button x="372" y="112" w="56" h="22" text="Apply"
onclick="{ApplyLootRule}" />
<button x="436" y="112" w="48" h="22" text="Add"
onclick="{AddLootRule}" />
<button x="492" y="112" w="68" h="22" text="Remove"
onclick="{RemoveLootRule}" />
<button x="568" y="112" w="28" h="22" text="↑"
onclick="{MoveLootRuleUp}" tooltip="Move the selected loot rule up." />
<button x="602" y="112" w="28" h="22" text="↓"
onclick="{MoveLootRuleDown}" tooltip="Move the selected loot rule down." />
<toggle x="4" y="142" w="164" h="20" text="Loot All Corpses"
checked="{LootAllCorpsesEnabled}" onclick="{ToggleLootAllCorpses}" />
<toggle x="172" y="142" w="176" h="20" text="Loot Fellow Corpses"
checked="{LootFellowCorpsesEnabled}"
onclick="{ToggleLootFellowCorpses}" />
<toggle x="352" y="142" w="174" h="20" text="Loot Only Rare Corpses"
checked="{LootOnlyRareCorpsesEnabled}"
onclick="{ToggleLootOnlyRareCorpses}" />
<toggle x="530" y="142" w="196" h="20" text="Read Unknown Scrolls"
checked="{ReadUnknownScrollsEnabled}"
onclick="{ToggleReadUnknownScrolls}" />
<label x="4" y="170" text="{LootRangeText}" color="#FFC7B98F" />
<button x="152" y="164" w="28" h="21" text="-"
onclick="{LootRangeDown}" tooltip="Decrease corpse-looting range." />
<button x="186" y="164" w="28" h="21" text="+"
onclick="{LootRangeUp}" tooltip="Increase corpse-looting range." />
<label x="4" y="194" text="{LootEditorNotice}" color="#FF9B9072" />
</group>
</panel>