From 96ad2f0988770d46b0109d76c276ef7674e85445 Mon Sep 17 00:00:00 2001 From: LostPoE <3904973+LostPoE@users.noreply.github.com> Date: Mon, 7 Sep 2026 10:43:42 -0400 Subject: [PATCH] Add Radar paths to Ritual altars Ritual altars had no target entry, so Radar drew no path to them. Target Metadata/Terrain/Leagues/Ritual/RitualRuneObject rather than RitualRuneInteractable. The interactable is an IngameIcon that only loads within ~160 grid units, while the terrain object loads at 490+, so routes appear from across the map instead of only once you are already standing on the altar. ExpectedCount 99 keeps each altar its own cluster rather than averaging several into one useless midpoint. Retiring the route needed more than the existing check. An altar is not a chest: it keeps a valid entity for the rest of the map once its ritual has been run, and carries no Chest component, so IsOpened could never retire it. current_state, traced through one altar's whole life in a map: 0 the pack around the altar is alive and it cannot be clicked 1 the pack is dead, interaction_enabled flips to 1, ritual can start 2 the ritual is running 3 the ritual is done and isTargetable goes false Only 3 retires the route. States 0 and 2 are both unclickable but still worth walking to, so keying removal on clickability would erase the path exactly when it is most useful. The watch loop also could not stay bound to the entity it was handed. The client only keeps entities within roughly 250 grid units per axis, so walking to one altar unloads the others; the existing IsValid check then retired their routes, and EntityAdded would not rebuild them because their positions were already known, so every altar you walked away from lost its path for good. Altars are now looked up by entity id in the live list on each poll -- ids survive the unload where the reference does not -- and an altar that is not currently loaded reads as unobservable rather than finished. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TCFwWMDqScaqSF3EByERM4 --- Radar.Pathfinding.cs | 85 +++++++++++++++++++++++++++++++++++++++++++- targets.json | 6 ++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/Radar.Pathfinding.cs b/Radar.Pathfinding.cs index 380b6d4..ece4e90 100644 --- a/Radar.Pathfinding.cs +++ b/Radar.Pathfinding.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using ExileCore2.PoEMemory.Components; using ExileCore2.PoEMemory.MemoryObjects; +using ExileCore2.Shared.Enums; using ExileCore2.Shared.Helpers; using GameOffsets2; using GameOffsets2.Native; @@ -85,7 +86,7 @@ Color GetMapColor() => Settings.PathfindingSettings.UseRainbowColorsForMapPaths async Task CheckEntity() { - while (entity.IsValid && entity.GetComponent()?.IsOpened != true) + while (!IsRouteFinished(entity)) { await Task.Delay(100, cancellationToken); } @@ -119,6 +120,88 @@ async Task CheckEntity() }, cancellationToken); } + // A ritual altar is not a chest: it keeps a valid entity for the whole map after its ritual + // has been run, and carries no Chest component to report that it is finished, so its + // completion has to be read off the state machine instead. + private const string RitualRunePathPrefix = "Metadata/Terrain/Leagues/Ritual/RitualRune"; + private const string CurrentStateName = "current_state"; + // current_state, traced through one altar's whole life in a map: + // 0 the pack around the altar is still alive and it cannot be clicked + // 1 the pack is dead, interaction_enabled flips to 1, the ritual can be started + // 2 the ritual is running + // 3 the ritual is done, the zone's rituals_completed has gone up, and the altar + // reports isTargetable false + // Only 3 should drop the route. States 0 and 2 are both unclickable but still worth + // walking to, so keying removal on clickability instead would erase the path exactly + // when it is most useful. + private const int RitualRuneCompletedState = 3; + + /// + /// Whether the route to an entity target should be retired. + /// + /// + /// For most targets an entity that has gone invalid is gone for good, so the original rule -- + /// invalid, or an opened chest -- still holds. A ritual altar breaks it: the client only keeps + /// entities within roughly 250 grid units per axis, and a map's altars sit far enough apart + /// that walking to one unloads the others. Retiring on invalidity there deletes the route to + /// every altar except the one being stood on, and will not + /// rebuild it when the altar loads again, because its position is already known. So an altar + /// is retired only on a positively observed completed state. + /// + private bool IsRouteFinished(Entity entity) + { + if (entity.Path.StartsWith(RitualRunePathPrefix, StringComparison.Ordinal)) + { + return IsRitualAltarSpent(entity.Id); + } + + return !entity.IsValid || entity.GetComponent()?.IsOpened == true; + } + + /// + /// Reads an altar out of the live entity list by id instead of trusting the reference the + /// route was created with, which goes invalid every time the player walks out of range. Entity + /// ids survive that unload, so they identify the altar across it where the reference does not. + /// + /// + /// False when the altar is not currently loaded: out of range is unobservable, not finished. + /// + private bool IsRitualAltarSpent(uint entityId) + { + foreach (var entity in GameController.EntityListWrapper.ValidEntitiesByType[EntityType.Terrain]) + { + if (entity.Id == entityId) + { + return GetState(entity, CurrentStateName) is RitualRuneCompletedState; + } + } + + return false; + } + + /// + /// One named state off an entity's state machine, or null when the entity has no state + /// machine or does not carry that state. + /// + private static int? GetState(Entity entity, string stateName) + { + var states = entity.GetComponent()?.States; + if (states == null) + { + return null; + } + + foreach (var state in states) + { + if (state.Name == stateName) + { + return (int)state.Value; + } + } + + return null; + } + private Task AddRoute(Vector2 target, Action> callback, CancellationToken cancellationToken) { if (_addRouteAction == null) diff --git a/targets.json b/targets.json index de40d34..c87a0a4 100644 --- a/targets.json +++ b/targets.json @@ -2000,6 +2000,12 @@ "DisplayName": "League Mechanic", "TargetType": "Entity" }, + { + "Name": "Metadata/Terrain/Leagues/Ritual/RitualRuneObject", + "ExpectedCount": 99, + "DisplayName": "Ritual Altar", + "TargetType": "Entity" + }, { "Name": "*", "Rooms": [ "*rune*" ],