diff --git a/CustomAilments.json b/CustomAilments.json index 4d808c6..af08709 100644 --- a/CustomAilments.json +++ b/CustomAilments.json @@ -14,12 +14,9 @@ "puncture_moving" ], "Burning": [ - "demon_righteous_fire_aura", "fire_damage_and_ignite", "ground_fire_burn", - "ignited", - "righteous_fire_aura", - "searing_bond_in_beam" + "ignited" ], "Chilled": [ "chilled", @@ -32,17 +29,18 @@ "corrupted_blood_rain" ], "Cursed": [ - "curse_assassins_mark", "curse_chaos_weakness", "curse_cold_weakness", "curse_elemental_weakness", "curse_enfeeble", "curse_fire_weakness", "curse_lightning_weakness", - "curse_newpunishment", "curse_temporal_chains", - "curse_vulnerability", - "curse_warlords_mark" + "curse_vulnerability" + ], + "Electrocuted": [ + "electrocute", + "electrocuted" ], "Exposed": [ "reduced_cold_resistance_from_skill", @@ -65,18 +63,12 @@ "caustic_cloud", "chaos_bond_in_beam", "ground_desecration", - "poison", - "viper_strike_orb" + "poison" ], "Shocked": [ "ground_lightning_shock", "lightning_damage_and_shock", "seawitch_lightning_beam", "shocked" - ], - "Unable To Recover": [ - "atlas_orion_meteor_ground", - "maven_rotating_beam_debuff", - "maven_cutter_beam_debuff" ] -} \ No newline at end of file +} diff --git a/Profile.cs b/Profile.cs index f15996f..aaef289 100644 --- a/Profile.cs +++ b/Profile.cs @@ -252,6 +252,14 @@ public void FocusLost() _groupImportObject = null; } + internal void ReleaseCompilationContexts() + { + foreach (var group in Groups) + { + group.ReleaseCompilationContexts(); + } + } + private void DrawSettingsHorizontal(RuleState state, ReAgentSettings settings) { if (ImGui.BeginTabBar("Rule groups", ImGuiTabBarFlags.AutoSelectNewTabs | ImGuiTabBarFlags.Reorderable | ImGuiTabBarFlags.FittingPolicyScroll)) @@ -343,4 +351,4 @@ private void MoveGroup(int sourceIndex, int targetIndex) Groups.RemoveAt(sourceIndex); Groups.Insert(targetIndex, movedItem); } -} \ No newline at end of file +} diff --git a/README.md b/README.md index 1a700de..fddcfa8 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,33 @@ -# ReAgent +# ReAgent — PoE2 -If you like it, you can donate via: +Generic user-authored rule engine for ExileCore2. This is an automation +framework, not a passive overlay. -BTC: bc1qke67907s6d5k3cm7lx7m020chyjp9e8ysfwtuz +## Logic -ETH: 0x3A37B3f57453555C2ceabb1a2A4f55E0eB969105 +1. Build a read-only `RuleState` snapshot containing player vitals, Life, buffs, + skills, flasks, charges, monsters, map stats, and UI visibility. +2. Compile rules as Dynamic-LINQ v1 or Roslyn C# v2 and group them by area/state + conditions. +3. Evaluate enabled groups each frame behind foreground/escape/dead/grace gates. +4. Apply configured effects: overlays, flags/timers, PluginBridge calls, + keyboard/mouse actions, delayed effects, and optional disconnect actions. +5. Persist profiles/rules locally and isolate compilation/runtime failures per + rule. Detach callbacks and clear state on reload/dispose. -# Docs +PoE2-specific behavior includes the two-flask model and data-driven ailment +names. Review every profile before enabling: user rules can send input. -Some docs: https://excore2.github.io/ReAgent/ +Entity projections fail closed when an ExileCore2 `ValidEntitiesByType` bucket is +not present during startup or an area transition. Roslyn v2 rule contexts are +also unloaded when a rule is rebuilt or the plugin is disposed, limiting stale +collectible assembly accumulation during profile editing and hot reload. + +## Status + +Build: **PASS**. Classification: **CURRENT_WITH_WARNINGS**; runtime semantics +and safety are profile-dependent. + +Documentation: [upstream ReAgent docs](https://excore2.github.io/ReAgent/). +Detailed report: [PoE2 plugin catalog](../../README.md) · +[audit](../../../docs/plugins/ReAgent/AUDIT.md). diff --git a/ReAgent.cs b/ReAgent.cs index e8ff5a0..c734217 100644 --- a/ReAgent.cs +++ b/ReAgent.cs @@ -273,6 +273,9 @@ private string GetNewProfileName(string prefix) public override void Render() { + if (!Settings.Enable) + return; + if (Settings.Profiles.Count == 0) { Settings.Profiles.Add(GetNewProfileName("New profile "), Profile.CreateWithDefaultGroup()); @@ -443,6 +446,34 @@ public override void Render() } } + public override void OnPluginDestroyForHotReload() + { + ClearRuntimeState(); + base.OnPluginDestroyForHotReload(); + } + + public override void Dispose() + { + ClearRuntimeState(); + base.Dispose(); + } + + private void ClearRuntimeState() + { + _pendingSideEffects.Clear(); + _actionInfo.Clear(); + foreach (var profile in Settings.Profiles.Values) + { + profile?.ReleaseCompilationContexts(); + } + + foreach (var loadedTexture in _loadedTextures) + { + try { Graphics.DisposeTexture(loadedTexture); } catch { } + } + _loadedTextures.Clear(); + } + private static Color ColorFromName(string color) { return Color.FromName(color); @@ -518,4 +549,4 @@ private bool ShouldExecute(out string state) state = "Ready"; return true; } -} \ No newline at end of file +} diff --git a/ReAgent.csproj b/ReAgent.csproj index 7bc2b9e..da03fa3 100644 --- a/ReAgent.csproj +++ b/ReAgent.csproj @@ -11,6 +11,7 @@ true + @@ -37,6 +38,6 @@ - + diff --git a/Rule.cs b/Rule.cs index bf7bea5..48c5704 100644 --- a/Rule.cs +++ b/Rule.cs @@ -82,6 +82,7 @@ public Keys? Key public HotkeyNodeValue KeyV2 = new HotkeyNodeValue(Keys.D0); public int SyntaxVersion; private Lazy<(Func> Func, string Exception)> _compilationResult; + private AssemblyLoadContext _assemblyLoadContext; private string _lastException; private ulong _exceptionCounter; private static readonly InteractiveAssemblyLoader loader; @@ -145,7 +146,7 @@ public void Display(RuleState state, bool expand) { ImGui.TextWrapped("Rule source"); ImGui.SameLine(); - var syntaxState = SyntaxVersion switch { 1 => false, 2 => true }; + var syntaxState = SyntaxVersion switch { 1 => false, 2 => true, _ => true }; if (ImGui.Checkbox("Use new syntax", ref syntaxState)) { SyntaxVersion = syntaxState ? 2 : 1; @@ -205,8 +206,9 @@ public void Display(RuleState state, bool expand) private void ResetFunction() { + ReleaseCompilationContext(); _exceptionCounter = 0; - _compilationResult = new(SyntaxVersion switch { 1 => RebuildFunctionV1, 2 => RebuildFunctionV2 }, LazyThreadSafetyMode.None); + _compilationResult = new(SyntaxVersion switch { 1 => RebuildFunctionV1, 2 => RebuildFunctionV2, _ => RebuildFunctionV2 }, LazyThreadSafetyMode.None); } private (Func> Func, string LastException) RebuildFunctionV1() @@ -263,20 +265,20 @@ private void ResetFunction() { case RuleActionType.Key: { - var @delegate = DelegateCompiler.CompileDelegate>(RuleSource, ScriptOptions, CreateAlc()); + var @delegate = DelegateCompiler.CompileDelegate>(RuleSource, ScriptOptions, CreateRuleAlc()); return (s => @delegate(s) ? [new PressKeySideEffect(KeyV2 ?? throw new Exception("Key is not assigned"))] : [], null); } case RuleActionType.SingleSideEffect: { - var @delegate = DelegateCompiler.CompileDelegate>(RuleSource, ScriptOptions, CreateAlc()); + var @delegate = DelegateCompiler.CompileDelegate>(RuleSource, ScriptOptions, CreateRuleAlc()); return (s => @delegate(s) switch { { } sideEffect => [sideEffect], _ => Enumerable.Empty() }, null); } case RuleActionType.MultipleSideEffects: { - var @delegate = DelegateCompiler.CompileDelegate>>(RuleSource, ScriptOptions, CreateAlc()); + var @delegate = DelegateCompiler.CompileDelegate>>(RuleSource, ScriptOptions, CreateRuleAlc()); return (s => @delegate(s) switch { { } sideEffects => sideEffects, _ => Enumerable.Empty() }, null); } default: @@ -285,10 +287,25 @@ private void ResetFunction() } catch (Exception ex) { + ReleaseCompilationContext(); return (null, $"Expression compilation failed: {ex.Message}"); } } + internal void ReleaseCompilationContext() + { + var context = Interlocked.Exchange(ref _assemblyLoadContext, null); + context?.Unload(); + } + + private AssemblyLoadContext CreateRuleAlc() + { + ReleaseCompilationContext(); + var context = CreateAlc(); + _assemblyLoadContext = context; + return context; + } + private static AssemblyLoadContext CreateAlc() { var assemblyLoadContext = new AssemblyLoadContext($"bbb{Guid.NewGuid()}", true); diff --git a/RuleGroup.cs b/RuleGroup.cs index ba761fc..1b20d59 100644 --- a/RuleGroup.cs +++ b/RuleGroup.cs @@ -218,6 +218,14 @@ public IEnumerable Evaluate(RuleState state) } } + internal void ReleaseCompilationContexts() + { + foreach (var rule in Rules) + { + rule.ReleaseCompilationContext(); + } + } + private void RemoveAt(int index) { Rules.RemoveAt(index); @@ -229,4 +237,4 @@ private void MoveRule(int sourceIndex, int targetIndex) Rules.RemoveAt(sourceIndex); Rules.Insert(targetIndex, movedItem); } -} \ No newline at end of file +} diff --git a/SideEffects/DisconnectSideEffect.cs b/SideEffects/DisconnectSideEffect.cs index d43f00d..e4647db 100644 --- a/SideEffects/DisconnectSideEffect.cs +++ b/SideEffects/DisconnectSideEffect.cs @@ -9,7 +9,6 @@ namespace ReAgent.SideEffects; [DynamicLinqType] [Api] -[method: Api] public record DisconnectSideEffect : ISideEffect { public SideEffectApplicationResult Apply(RuleState state) @@ -108,4 +107,4 @@ private enum TcpTableClass TcpTableOwnerModuleConnections, TcpTableOwnerModuleAll } -} \ No newline at end of file +} diff --git a/State/CustomDynamicLinqCustomTypeProvider.cs b/State/CustomDynamicLinqCustomTypeProvider.cs index ac2dc94..b8f847e 100644 --- a/State/CustomDynamicLinqCustomTypeProvider.cs +++ b/State/CustomDynamicLinqCustomTypeProvider.cs @@ -11,9 +11,10 @@ namespace ReAgent.State; public sealed class CustomDynamicLinqCustomTypeProvider : AbstractDynamicLinqCustomTypeProvider, - IDynamicLinkCustomTypeProvider, IDynamicLinqCustomTypeProvider { + public CustomDynamicLinqCustomTypeProvider() : base(Array.Empty()) { } + private HashSet _cachedCustomTypes; private Dictionary> _cachedExtensionMethods; @@ -52,4 +53,4 @@ private Dictionary> GetExtensionMethodsInternal() .GroupBy(x => x.GetParameters()[0].ParameterType) .ToDictionary(key => key.Key, methods => methods.ToList()); } -} \ No newline at end of file +} diff --git a/State/FlaskInfo.cs b/State/FlaskInfo.cs index 1dc4167..09d7c87 100644 --- a/State/FlaskInfo.cs +++ b/State/FlaskInfo.cs @@ -25,10 +25,7 @@ public record FlaskInfo( public static FlaskInfo From( GameController state, - List flaskItems, - ServerInventory.InventSlotItem flaskItem, - int index, - RuleInternalState internalState) + ServerInventory.InventSlotItem flaskItem) { if (flaskItem?.Address is 0 or null || flaskItem.Item?.Address is null or 0) { @@ -66,25 +63,30 @@ public static FlaskInfo From( return new FlaskInfo(active, canbeUsed, chargeComponent?.NumCharges ?? 0, chargeComponent?.ChargesMax ?? 1, chargeComponent?.ChargesPerUse ?? 1, className, baseName, uniqueName, canBeUsedIn); } - private static readonly string[] LifeFlaskBuffs = { "flask_effect_life" }; - - private static readonly string[] ManaFlaskBuffs = - { - "flask_effect_mana", - "flask_effect_mana_not_removed_when_full", - "flask_instant_mana_recovery_at_end_of_effect" - }; + // These are the only host members currently available for distinguishing + // life/mana/hybrid flasks in the pinned PoE2 preview. Keep them named and + // fail closed so a future layout change cannot turn a stale read into a + // false-positive automation trigger. + private const int FlaskTypePointerOffset = 0x28; + private const int FlaskTypeValueOffset = 0x20; + private const int CustomBuffPointerOffset = 0x18; + private const int CustomBuffPointerIndex = 0x0; private static IEnumerable GetFlaskBuffNames(Flask flask) { - var type = flask.M.Read(flask.Address + 0x28, 0x20); - return type switch + try { - 1 => LifeFlaskBuffs, - 2 => ManaFlaskBuffs, - 3 => LifeFlaskBuffs.Concat(ManaFlaskBuffs), - 4 when flask.M.ReadStringU(flask.M.Read(flask.Address + 0x28, 0x18, 0x0)) is { } s and not "" => new[] { s }, - _ => Enumerable.Empty() - }; + var type = flask.M.Read(flask.Address + FlaskTypePointerOffset, FlaskTypeValueOffset); + var customBuff = type == 4 + ? flask.M.ReadStringU(flask.M.Read(flask.Address + FlaskTypePointerOffset, CustomBuffPointerOffset, CustomBuffPointerIndex)) + : null; + return FlaskLayoutClassifier.GetBuffNames(type, customBuff); + } + catch + { + // A stale/unknown memory layout must disable classification, not + // break the rule-state snapshot or execute a wrong flask rule. + return Array.Empty(); + } } } diff --git a/State/FlaskLayoutClassifier.cs b/State/FlaskLayoutClassifier.cs new file mode 100644 index 0000000..caa5ed4 --- /dev/null +++ b/State/FlaskLayoutClassifier.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace ReAgent.State; + +/// +/// Pure PoE2 flask-type mapping. Memory reads stay in +/// while this stable semantic projection can be verified without a host. +/// +public static class FlaskLayoutClassifier +{ + private static readonly string[] LifeFlaskBuffs = ["flask_effect_life"]; + private static readonly string[] ManaFlaskBuffs = + [ + "flask_effect_mana", + "flask_effect_mana_not_removed_when_full", + "flask_instant_mana_recovery_at_end_of_effect" + ]; + private static readonly string[] HybridFlaskBuffs = + [ + "flask_effect_life", + "flask_effect_mana", + "flask_effect_mana_not_removed_when_full", + "flask_instant_mana_recovery_at_end_of_effect" + ]; + + public static IReadOnlyList GetBuffNames(int flaskType, string customBuff) + { + return flaskType switch + { + 1 => LifeFlaskBuffs, + 2 => ManaFlaskBuffs, + 3 => HybridFlaskBuffs, + 4 when customBuff is { Length: > 0 } => [customBuff], + _ => Array.Empty() + }; + } +} diff --git a/State/FlasksInfo.cs b/State/FlasksInfo.cs index 25bea84..808b22d 100644 --- a/State/FlasksInfo.cs +++ b/State/FlasksInfo.cs @@ -38,7 +38,7 @@ public FlasksInfo(GameController controller, RuleInternalState internalState) var flaskInventory = controller.IngameState.ServerData.PlayerInventories.LastOrDefault(x => x.TypeId == InventoryNameE.Flask1); var flaskItems = Enumerable.Range(0, FlaskCount).Select(i => flaskInventory?.Inventory?[i, 0]).ToList(); _flasks = flaskItems - .Select((f,i) => FlaskInfo.From(controller, flaskItems, f, i, internalState)) + .Select(f => FlaskInfo.From(controller, f)) .ToList(); } -} \ No newline at end of file +} diff --git a/State/NearbyMonsterInfo.cs b/State/NearbyMonsterInfo.cs index e0063a5..c37515e 100644 --- a/State/NearbyMonsterInfo.cs +++ b/State/NearbyMonsterInfo.cs @@ -159,7 +159,14 @@ public NearbyMonsterInfo(ReAgent plugin) return; } - foreach (var entity in plugin.GameController.EntityListWrapper.ValidEntitiesByType[EntityType.Monster]) + if (plugin.GameController.EntityListWrapper?.ValidEntitiesByType is not { } byType || + !byType.TryGetValue(EntityType.Monster, out var entities) || entities is null) + { + FriendlyMonsters = friendlyMonsters; + return; + } + + foreach (var entity in entities) { if (!IsValidMonster(plugin, entity, true, false)) { @@ -205,4 +212,4 @@ public static bool IsValidMonster(ReAgent plugin, Entity entity, bool checkIsAli public IEnumerable GetMonsters(int range, MonsterRarity rarity) => _monsters.TakeWhile(x => x.Key <= range).SelectMany(x => x.Value).Where(x => (x.Rarity & rarity) != 0); -} \ No newline at end of file +} diff --git a/State/RuleInternalState.cs b/State/RuleInternalState.cs index aaf8dba..d4bd7c1 100644 --- a/State/RuleInternalState.cs +++ b/State/RuleInternalState.cs @@ -1,4 +1,6 @@ using System; +#nullable enable + using System.Collections.Generic; using System.Numerics; using System.Windows.Forms; @@ -18,8 +20,7 @@ public class RuleInternalState public List<(string Text, Vector2 Position, string Color)> TextToDisplay { get; } = new(); public List<(string Text, Vector2 Position, Vector2 Size, float Fraction, string Color, string BackgroundColor, string TextColor)> ProgressBarsToDisplay { get; } = new(); public bool AccessForbidden { get; set; } - public RuleGroup CurrentGroup { get; private set; } - public Dictionary TinctureUsageTracker { get; } = []; + public RuleGroup CurrentGroup { get; private set; } = new(""); public bool ChatTitlePanelVisible { get; set; } @@ -59,4 +60,4 @@ public void Dispose() private readonly RuleInternalState _state; private readonly RuleGroup _oldGroup; } -} \ No newline at end of file +} diff --git a/State/RuleState.cs b/State/RuleState.cs index ea91bdd..e685671 100644 --- a/State/RuleState.cs +++ b/State/RuleState.cs @@ -95,30 +95,41 @@ public RuleState(ReAgent plugin, RuleInternalState internalState) Flasks = new FlasksInfo(controller, InternalState); Player = new MonsterInfo(controller, player); _nearbyMonsterInfo = new Lazy(() => new NearbyMonsterInfo(plugin), LazyThreadSafetyMode.None); - _miscellaneousObjects = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.MiscellaneousObjects].Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _noneEntities = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.None].Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _ingameiconObjects = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.IngameIcon].Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _miniMonoliths = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.MiniMonolith].Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _chests = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.Chest].Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _terrainEntities = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.Terrain].Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _allMonsters = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.Monster] + _miscellaneousObjects = new Lazy>(() => EntitiesOfType(EntityType.MiscellaneousObjects).Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); + _noneEntities = new Lazy>(() => EntitiesOfType(EntityType.None).Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); + _ingameiconObjects = new Lazy>(() => EntitiesOfType(EntityType.IngameIcon).Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); + _miniMonoliths = new Lazy>(() => EntitiesOfType(EntityType.MiniMonolith).Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); + _chests = new Lazy>(() => EntitiesOfType(EntityType.Chest).Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); + _terrainEntities = new Lazy>(() => EntitiesOfType(EntityType.Terrain).Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); + _allMonsters = new Lazy>(() => EntitiesOfType(EntityType.Monster) .Where(e => NearbyMonsterInfo.IsValidMonster(plugin, e, false, false)) .Select(x => new MonsterInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _hiddenMonsters = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.Monster] + _hiddenMonsters = new Lazy>(() => EntitiesOfType(EntityType.Monster) .Where(e => NearbyMonsterInfo.IsValidMonster(plugin, e, false, true)) .Select(x => new MonsterInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _corpses = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.Monster] + _corpses = new Lazy>(() => EntitiesOfType(EntityType.Monster) .Where(e => NearbyMonsterInfo.IsValidMonster(plugin, e, false, false)) .Where(x => x.IsDead) .Select(x => new MonsterInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _effects = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.Effect].Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _allPlayers = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.Player] + _effects = new Lazy>(() => EntitiesOfType(EntityType.Effect).Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); + _allPlayers = new Lazy>(() => EntitiesOfType(EntityType.Player) .Select(x => new MonsterInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); - _portals = new Lazy>(() => controller.EntityListWrapper.ValidEntitiesByType[EntityType.TownPortal] + _portals = new Lazy>(() => EntitiesOfType(EntityType.TownPortal) .Select(x => new EntityInfo(controller, x)).ToList(), LazyThreadSafetyMode.None); } } + private IEnumerable EntitiesOfType(EntityType type) + { + if (_controller?.EntityListWrapper?.ValidEntitiesByType is { } byType && + byType.TryGetValue(type, out var entities) && entities is not null) + { + return entities; + } + + return Enumerable.Empty(); + } + [Api] public StatDictionary MapStats => _mapStats.Value; @@ -274,4 +285,4 @@ public bool SinceLastActivation(double minTime) => [Api] public Vector2 MousePosition => _controller.IngameState.ServerData.WorldMousePosition.WorldToGrid(); -} \ No newline at end of file +} diff --git a/State/VitalsInfo.cs b/State/VitalsInfo.cs index e10fd83..115f8a6 100644 --- a/State/VitalsInfo.cs +++ b/State/VitalsInfo.cs @@ -1,10 +1,19 @@ -using ExileCore2.PoEMemory.Components; +using System; +using System.Reflection; +using ExileCore2.PoEMemory.Components; namespace ReAgent.State; [Api] public class VitalsInfo { + private static readonly PropertyInfo WardProperty = + typeof(Life).GetProperty("Ward", BindingFlags.Instance | BindingFlags.Public); + private static readonly PropertyInfo WardCurrentProperty = + WardProperty?.PropertyType.GetProperty("Current", BindingFlags.Instance | BindingFlags.Public); + private static readonly PropertyInfo WardMaxProperty = + WardProperty?.PropertyType.GetProperty("Max", BindingFlags.Instance | BindingFlags.Public); + [Api] public Vital HP { get; } @@ -13,7 +22,7 @@ public class VitalsInfo [Api] public Vital Mana { get; } - + [Api] public Vital Ward { get; } @@ -22,6 +31,27 @@ public VitalsInfo(Life lifeComponent) HP = Vital.From(lifeComponent.Health); ES = Vital.From(lifeComponent.EnergyShield); Mana = Vital.From(lifeComponent.Mana); - Ward = Vital.From(lifeComponent.Ward); + Ward = ReadOptionalWard(lifeComponent); + } + + private static Vital ReadOptionalWard(Life lifeComponent) + { + if (lifeComponent == null || WardProperty == null || WardCurrentProperty == null || WardMaxProperty == null) + { + return new Vital(0, 0); + } + + try + { + var ward = WardProperty.GetValue(lifeComponent); + var current = WardCurrentProperty.GetValue(ward); + var max = WardMaxProperty.GetValue(ward); + return new Vital(Convert.ToDouble(current ?? 0), Convert.ToDouble(max ?? 0)); + } + catch (TargetInvocationException) { return new Vital(0, 0); } + catch (ArgumentException) { return new Vital(0, 0); } + catch (InvalidCastException) { return new Vital(0, 0); } + catch (FormatException) { return new Vital(0, 0); } + catch (OverflowException) { return new Vital(0, 0); } } -} \ No newline at end of file +} diff --git a/tests/ReAgent.Tests/Program.cs b/tests/ReAgent.Tests/Program.cs new file mode 100644 index 0000000..5746a6e --- /dev/null +++ b/tests/ReAgent.Tests/Program.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using ReAgent.State; + +var tests = new (string Name, Action Body)[] +{ + ("life flask maps to life buff", LifeFlask), + ("mana flask maps to all mana buffs", ManaFlask), + ("hybrid flask maps to life and mana buffs", HybridFlask), + ("custom flask maps only non-empty custom buff", CustomFlask), + ("unknown layout fails closed", UnknownLayout), +}; + +var failures = 0; +foreach (var (name, body) in tests) +{ + try + { + body(); + Console.WriteLine($"PASS {name}"); + } + catch (Exception ex) + { + failures++; + Console.WriteLine($"FAIL {name}: {ex.Message}"); + } +} + +if (failures != 0) + throw new InvalidOperationException($"{failures} ReAgent fixture test(s) failed."); + +Console.WriteLine($"{tests.Length} ReAgent fixture tests passed."); + +static void LifeFlask() +{ + var buffs = FlaskLayoutClassifier.GetBuffNames(1, null); + Assert(buffs.SequenceEqual(["flask_effect_life"]), "life mapping"); +} + +static void ManaFlask() +{ + var buffs = FlaskLayoutClassifier.GetBuffNames(2, null); + Assert(buffs.Count == 3 && buffs.Contains("flask_effect_mana"), "mana mapping"); +} + +static void HybridFlask() +{ + var buffs = FlaskLayoutClassifier.GetBuffNames(3, null); + Assert(buffs.Count == 4 && buffs.Contains("flask_effect_life") && buffs.Contains("flask_effect_mana"), "hybrid mapping"); +} + +static void CustomFlask() +{ + var buffs = FlaskLayoutClassifier.GetBuffNames(4, "custom_flask_effect"); + Assert(buffs.SequenceEqual(["custom_flask_effect"]), "custom mapping"); + Assert(FlaskLayoutClassifier.GetBuffNames(4, "").Count == 0, "empty custom mapping fails closed"); +} + +static void UnknownLayout() +{ + Assert(FlaskLayoutClassifier.GetBuffNames(0, null).Count == 0, "unknown type fails closed"); + Assert(FlaskLayoutClassifier.GetBuffNames(99, "stale").Count == 0, "unknown type ignores stale custom data"); +} + +static void Assert(bool condition, string message) +{ + if (!condition) + throw new InvalidOperationException(message); +} diff --git a/tests/ReAgent.Tests/ReAgent.Tests.csproj b/tests/ReAgent.Tests/ReAgent.Tests.csproj new file mode 100644 index 0000000..40268a4 --- /dev/null +++ b/tests/ReAgent.Tests/ReAgent.Tests.csproj @@ -0,0 +1,13 @@ + + + net8.0 + Exe + latest + disable + false + + + + + +