feat(mosstank): add VTank-style automation PoC
This commit is contained in:
parent
f6fe0f2a4f
commit
4e6e9bc9d9
212 changed files with 49462 additions and 416 deletions
|
|
@ -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>
|
||||
|
|
|
|||
408
src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs
Normal file
408
src/AcDream.Plugins.MossTank/AttackSpellCatalog.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
173
src/AcDream.Plugins.MossTank/AutoAttackPower.cs
Normal file
173
src/AcDream.Plugins.MossTank/AutoAttackPower.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
2059
src/AcDream.Plugins.MossTank/CombatController.cs
Normal file
2059
src/AcDream.Plugins.MossTank/CombatController.cs
Normal file
File diff suppressed because it is too large
Load diff
173
src/AcDream.Plugins.MossTank/CombatFailureTracker.cs
Normal file
173
src/AcDream.Plugins.MossTank/CombatFailureTracker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
224
src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs
Normal file
224
src/AcDream.Plugins.MossTank/CombatItemDebuffPlanner.cs
Normal 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;
|
||||
}
|
||||
151
src/AcDream.Plugins.MossTank/CombatSettings.cs
Normal file
151
src/AcDream.Plugins.MossTank/CombatSettings.cs
Normal 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;
|
||||
}
|
||||
649
src/AcDream.Plugins.MossTank/Crafting.cs
Normal file
649
src/AcDream.Plugins.MossTank/Crafting.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
400
src/AcDream.Plugins.MossTank/DebuffScheduler.cs
Normal file
400
src/AcDream.Plugins.MossTank/DebuffScheduler.cs
Normal 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);
|
||||
420
src/AcDream.Plugins.MossTank/DispelController.cs
Normal file
420
src/AcDream.Plugins.MossTank/DispelController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs
Normal file
84
src/AcDream.Plugins.MossTank/Expressions/ExperienceMeter.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
789
src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs
Normal file
789
src/AcDream.Plugins.MossTank/Expressions/ExpressionEngine.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
204
src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs
Normal file
204
src/AcDream.Plugins.MossTank/Expressions/ExpressionRuntime.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs
Normal file
240
src/AcDream.Plugins.MossTank/Expressions/ExpressionValue.cs
Normal 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; }
|
||||
}
|
||||
1276
src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs
Normal file
1276
src/AcDream.Plugins.MossTank/Expressions/HostExpressionFunctions.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
152
src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs
Normal file
152
src/AcDream.Plugins.MossTank/Expressions/QuestTracker.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
68
src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs
Normal file
68
src/AcDream.Plugins.MossTank/Expressions/StatusHudManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
523
src/AcDream.Plugins.MossTank/FellowshipManager.cs
Normal file
523
src/AcDream.Plugins.MossTank/FellowshipManager.cs
Normal 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})";
|
||||
}
|
||||
}
|
||||
79
src/AcDream.Plugins.MossTank/GrenadeCatalog.cs
Normal file
79
src/AcDream.Plugins.MossTank/GrenadeCatalog.cs
Normal 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));
|
||||
}
|
||||
299
src/AcDream.Plugins.MossTank/InventoryMaintenance.cs
Normal file
299
src/AcDream.Plugins.MossTank/InventoryMaintenance.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
140
src/AcDream.Plugins.MossTank/ItemManaRecharge.cs
Normal file
140
src/AcDream.Plugins.MossTank/ItemManaRecharge.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
1766
src/AcDream.Plugins.MossTank/Looting.cs
Normal file
1766
src/AcDream.Plugins.MossTank/Looting.cs
Normal file
File diff suppressed because it is too large
Load diff
656
src/AcDream.Plugins.MossTank/Meta.cs
Normal file
656
src/AcDream.Plugins.MossTank/Meta.cs
Normal 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);
|
||||
}
|
||||
97
src/AcDream.Plugins.MossTank/MetaViewManager.cs
Normal file
97
src/AcDream.Plugins.MossTank/MetaViewManager.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
608
src/AcDream.Plugins.MossTank/MonsterExpression.cs
Normal file
608
src/AcDream.Plugins.MossTank/MonsterExpression.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
150
src/AcDream.Plugins.MossTank/MonsterRules.cs
Normal file
150
src/AcDream.Plugins.MossTank/MonsterRules.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
1046
src/AcDream.Plugins.MossTank/MossTankCommands.cs
Normal file
1046
src/AcDream.Plugins.MossTank/MossTankCommands.cs
Normal file
File diff suppressed because it is too large
Load diff
477
src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs
Normal file
477
src/AcDream.Plugins.MossTank/MossTankLootProfileStore.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
286
src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
Normal file
286
src/AcDream.Plugins.MossTank/MossTankMetaProfileStore.cs
Normal 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
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
46
src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs
Normal file
46
src/AcDream.Plugins.MossTank/MossTankProfileRecovery.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
922
src/AcDream.Plugins.MossTank/MossTankProfileStore.cs
Normal file
922
src/AcDream.Plugins.MossTank/MossTankProfileStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
437
src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
Normal file
437
src/AcDream.Plugins.MossTank/MossTankRouteProfileStore.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
1110
src/AcDream.Plugins.MossTank/Navigation.cs
Normal file
1110
src/AcDream.Plugins.MossTank/Navigation.cs
Normal file
File diff suppressed because it is too large
Load diff
315
src/AcDream.Plugins.MossTank/PetAutomation.cs
Normal file
315
src/AcDream.Plugins.MossTank/PetAutomation.cs
Normal 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})";
|
||||
}
|
||||
}
|
||||
51
src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs
Normal file
51
src/AcDream.Plugins.MossTank/PetDeviceCatalog.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
247
src/AcDream.Plugins.MossTank/ProfileGiveController.cs
Normal file
247
src/AcDream.Plugins.MossTank/ProfileGiveController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
73
src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs
Normal file
73
src/AcDream.Plugins.MossTank/SpellComponentPolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
1103
src/AcDream.Plugins.MossTank/VitalRecharge.cs
Normal file
1103
src/AcDream.Plugins.MossTank/VitalRecharge.cs
Normal file
File diff suppressed because it is too large
Load diff
164
src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs
Normal file
164
src/AcDream.Plugins.MossTank/VtankAmmunitionDatabase.cs
Normal 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);
|
||||
}
|
||||
121
src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv
Normal file
121
src/AcDream.Plugins.MossTank/VtankAmmunitionOptions.tsv
Normal 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
|
||||
|
81
src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs
Normal file
81
src/AcDream.Plugins.MossTank/VtankCraftDatabase.cs
Normal 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);
|
||||
}
|
||||
758
src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv
Normal file
758
src/AcDream.Plugins.MossTank/VtankCraftRecipes.tsv
Normal 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.
|
289
src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs
Normal file
289
src/AcDream.Plugins.MossTank/VtankDamageDatabase.cs
Normal 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
|
||||
""";
|
||||
}
|
||||
446
src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs
Normal file
446
src/AcDream.Plugins.MossTank/VtankLootProfileSerializer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
576
src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs
Normal file
576
src/AcDream.Plugins.MossTank/VtankLootRequirementEvaluator.cs
Normal 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.");
|
||||
}
|
||||
742
src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs
Normal file
742
src/AcDream.Plugins.MossTank/VtankMetaProfileSerializer.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
303
src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs
Normal file
303
src/AcDream.Plugins.MossTank/VtankNavRouteSerializer.cs
Normal 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));
|
||||
}
|
||||
221
src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs
Normal file
221
src/AcDream.Plugins.MossTank/VtankOptionCatalog.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue