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
+
+
+
+
+
+