From 5fd9e63aced7336c6876dc1b063f9213ba8ebd70 Mon Sep 17 00:00:00 2001 From: Wellwisher533 <308834484+Wellwisher533@users.noreply.github.com> Date: Thu, 27 Aug 2026 17:43:01 -0700 Subject: [PATCH 1/2] Target Ancestors' Rage by adjacent enemy density --- Py4GWCoreLib/Agent.py | 5 ++ Py4GWCoreLib/BuildMgr.py | 3 + Py4GWCoreLib/HeroAI/combat.py | 4 +- .../HeroAI/custom_skill_src/ritualist.py | 3 +- Py4GWCoreLib/HeroAI/targeting.py | 66 +++++++++++++++++++ Py4GWCoreLib/HeroAI/types.py | 1 + Py4GWCoreLib/SkillManager.py | 6 +- 7 files changed, 85 insertions(+), 3 deletions(-) diff --git a/Py4GWCoreLib/Agent.py b/Py4GWCoreLib/Agent.py index ca79e8f97..ff622559e 100644 --- a/Py4GWCoreLib/Agent.py +++ b/Py4GWCoreLib/Agent.py @@ -37,6 +37,11 @@ def IsValid(agent_id: int) -> bool: """ return Agent.GetAgentByID(agent_id) is not None + @staticmethod + def IsTargettable(agent_id: int) -> bool: + """Return whether Guild Wars permits selecting this agent as a target.""" + return bool(PyAgent.get_agent_is_targettable(agent_id)) + _agent_cache: dict[int, "AgentStruct"] = {} _living_cache: dict[int, "AgentLivingStruct"] = {} _item_cache: dict[int, "AgentItemStruct"] = {} diff --git a/Py4GWCoreLib/BuildMgr.py b/Py4GWCoreLib/BuildMgr.py index d64dd5d0e..cdfb49eb1 100644 --- a/Py4GWCoreLib/BuildMgr.py +++ b/Py4GWCoreLib/BuildMgr.py @@ -419,6 +419,7 @@ def ResolveAllyTarget( TargetLowestAllyMartial, TargetLowestAllyMelee, TargetLowestAllyRanged, + TargetAllyWithMostAdjacentEnemies, TargetAllyNonEnchanted, TargetAllyNonWeaponSpelled, TargetMinionNonEnchanted, @@ -455,6 +456,8 @@ def ResolveAllyTarget( return TargetAllyNonEnchanted(**range_kwargs) if target_allegiance == Skilltarget.NonWeaponSpelledAlly.value: return Player.GetAgentID() if TargetAllyNonWeaponSpelled(**range_kwargs) else 0 + if target_allegiance == Skilltarget.AllyWithAdjacentEnemies.value: + return TargetAllyWithMostAdjacentEnemies(distance=ally_distance) if target_allegiance in ( Skilltarget.Ally.value, diff --git a/Py4GWCoreLib/HeroAI/combat.py b/Py4GWCoreLib/HeroAI/combat.py index 088411300..98a7264b8 100644 --- a/Py4GWCoreLib/HeroAI/combat.py +++ b/Py4GWCoreLib/HeroAI/combat.py @@ -10,7 +10,7 @@ from Py4GWCoreLib.GlobalCache.HexRemovalPriority import get_hexed_ally_for_removal from Py4GWCoreLib.EnemyBlacklist import EnemyBlacklist from .custom_skill import CustomSkillClass -from .targeting import TargetLowestAlly, TargetLowestAllyEnergy, TargetClusteredEnemy, TargetLowestAllyCaster, TargetLowestAllyMartial, TargetLowestAllyMelee, TargetLowestAllyRanged, GetAllAlliesArray, TargetAllyWeaponSpell, TargetMinionOrAllyNonEnchanted, TargetMinionNonEnchanted, TargetAllyNonEnchanted, TargetAllyNonWeaponSpelled, TargetDeadPartyMember, IsResurrectablePartyMember +from .targeting import TargetLowestAlly, TargetLowestAllyEnergy, TargetClusteredEnemy, TargetLowestAllyCaster, TargetLowestAllyMartial, TargetLowestAllyMelee, TargetLowestAllyRanged, TargetAllyWithMostAdjacentEnemies, GetAllAlliesArray, TargetAllyWeaponSpell, TargetMinionOrAllyNonEnchanted, TargetMinionNonEnchanted, TargetAllyNonEnchanted, TargetAllyNonWeaponSpelled, TargetDeadPartyMember, IsResurrectablePartyMember from .targeting import GetEnemyAttacking, GetEnemyCasting, GetEnemyCastingSpell, GetEnemyCastingSpellOrChant, GetEnemyInjured, GetEnemyConditioned, GetEnemyHealthy from .targeting import GetEnemyHexed, GetEnemyDegenHexed, GetEnemyEnchanted, GetEnemyMoving, GetEnemyKnockedDown from .targeting import GetEnemyBleeding, GetEnemyPoisoned, GetEnemyCrippled @@ -899,6 +899,8 @@ def get_lowest_ally() -> int: v_target = TargetLowestAllyRanged(filter_skill_id=self.skills[slot].skill_id) if v_target == 0 and not targeting_strict: v_target = get_lowest_ally() + elif target_allegiance == Skilltarget.AllyWithAdjacentEnemies: + v_target = TargetAllyWithMostAdjacentEnemies() elif target_allegiance == Skilltarget.OtherAlly: if self.skills[slot].custom_skill_data.Nature == SkillNature.EnergyBuff.value: v_target = TargetLowestAllyEnergy(other_ally=True, filter_skill_id=self.skills[slot].skill_id, less_energy=self.skills[slot].custom_skill_data.Conditions.LessEnergy) diff --git a/Py4GWCoreLib/HeroAI/custom_skill_src/ritualist.py b/Py4GWCoreLib/HeroAI/custom_skill_src/ritualist.py index b58be9859..932fbcbd9 100644 --- a/Py4GWCoreLib/HeroAI/custom_skill_src/ritualist.py +++ b/Py4GWCoreLib/HeroAI/custom_skill_src/ritualist.py @@ -218,8 +218,9 @@ def __init__(self, skill_data): skill = CustomSkill() skill.SkillID = GLOBAL_CACHE.Skill.GetID("Ancestors_Rage") skill.SkillType = SkillType.Skill.value - skill.TargetAllegiance = Skilltarget.AllyMartial.value + skill.TargetAllegiance = Skilltarget.AllyWithAdjacentEnemies.value skill.Nature = SkillNature.Offensive.value + skill.Conditions.TargetingStrict = True skill_data[skill.SkillID] = skill skill = CustomSkill() diff --git a/Py4GWCoreLib/HeroAI/targeting.py b/Py4GWCoreLib/HeroAI/targeting.py index 87f1eb0e3..c75b106d1 100644 --- a/Py4GWCoreLib/HeroAI/targeting.py +++ b/Py4GWCoreLib/HeroAI/targeting.py @@ -109,6 +109,72 @@ def TargetLowestAlly(other_ally=False, filter_skill_id=0, distance=Range.Spellca return Utils.GetFirstFromArray(ally_array) +def TargetAllyWithMostAdjacentEnemies( + distance=Range.Spellcast.value, + adjacent_range=Range.Adjacent.value, + min_enemies=2, +): + """Select any living allied creature surrounded by enough living foes. + + Unlike the ordinary ally selectors, this intentionally includes spirits + and minions and does not apply persistent-effect filtering. Ancestors' + Rage ends after one second, so an effect-presence filter would incorrectly + suppress valid summoned targets whose effects are not observable. + """ + player_id = Player.GetAgentID() + player_xy = Player.GetXY() + candidate_ids = set(AgentArray.GetAllyArray() or []) + candidate_ids.update(AgentArray.GetSpiritPetArray() or []) + candidate_ids.update(AgentArray.GetMinionArray() or []) + candidate_ids.update(AgentArray.GetNPCMinipetArray() or []) + if player_id: + candidate_ids.add(player_id) + + candidates = [ + agent_id + for agent_id in candidate_ids + if agent_id + and Agent.IsValid(agent_id) + and Agent.IsTargettable(agent_id) + and Agent.IsAlive(agent_id) + and Utils.Distance(Agent.GetXY(agent_id), player_xy) <= distance + ] + + enemy_ids = Routines.Agents.GetFilteredEnemyArray( + player_xy[0], + player_xy[1], + distance + adjacent_range, + ) + enemy_ids = [ + enemy_id + for enemy_id in enemy_ids or [] + if Agent.IsValid(enemy_id) and Agent.IsAlive(enemy_id) + ] + + scored: list[tuple[int, float, int]] = [] + for agent_id in candidates: + target_xy = Agent.GetXY(agent_id) + enemy_count = sum( + 1 + for enemy_id in enemy_ids + if Utils.Distance(target_xy, Agent.GetXY(enemy_id)) <= adjacent_range + ) + if enemy_count < min_enemies: + continue + scored.append( + ( + -enemy_count, + Utils.Distance(target_xy, player_xy), + agent_id, + ) + ) + + if not scored: + return 0 + scored.sort() + return scored[0][2] + + def TargetMinionOrAllyNonEnchanted(filter_skill_id=0, distance=Range.Spellcast.value): minion_array = AgentArray.GetMinionArray() minion_array = AgentArray.Filter.ByDistance(minion_array, Player.GetXY(), distance) diff --git a/Py4GWCoreLib/HeroAI/types.py b/Py4GWCoreLib/HeroAI/types.py index a6b8f6574..b410056c0 100644 --- a/Py4GWCoreLib/HeroAI/types.py +++ b/Py4GWCoreLib/HeroAI/types.py @@ -143,6 +143,7 @@ class Skilltarget (IntEnum): ResurrectionAlly = 40 HexedAlly = 41 EnemyNotNearby = 42 + AllyWithAdjacentEnemies = 43 diff --git a/Py4GWCoreLib/SkillManager.py b/Py4GWCoreLib/SkillManager.py index 661c9d19f..543d6a044 100644 --- a/Py4GWCoreLib/SkillManager.py +++ b/Py4GWCoreLib/SkillManager.py @@ -1,7 +1,7 @@ from Py4GWCoreLib.HeroAI.custom_skill import CustomSkillClass from Py4GWCoreLib.HeroAI.types import SkillType,SkillNature, Skilltarget -from Py4GWCoreLib.HeroAI.targeting import TargetLowestAlly, TargetLowestAllyEnergy, TargetClusteredEnemy, TargetLowestAllyCaster, TargetLowestAllyMartial, TargetLowestAllyMelee, TargetLowestAllyRanged, GetAllAlliesArray +from Py4GWCoreLib.HeroAI.targeting import TargetLowestAlly, TargetLowestAllyEnergy, TargetClusteredEnemy, TargetLowestAllyCaster, TargetLowestAllyMartial, TargetLowestAllyMelee, TargetLowestAllyRanged, TargetAllyWithMostAdjacentEnemies, GetAllAlliesArray from Py4GWCoreLib.HeroAI.targeting import TargetMinionOrAllyNonEnchanted, TargetMinionNonEnchanted, TargetAllyNonEnchanted, TargetAllyNonWeaponSpelled from Py4GWCoreLib.HeroAI.targeting import GetEnemyAttacking, GetEnemyCasting, GetEnemyCastingSpell, GetEnemyInjured, GetEnemyConditioned, GetEnemyHealthy from Py4GWCoreLib.HeroAI.targeting import GetEnemyHexed, GetEnemyDegenHexed, GetEnemyEnchanted, GetEnemyMoving, GetEnemyKnockedDown @@ -457,6 +457,8 @@ def _TargetLowestAlly(other_ally=False, filter_skill_id=0): v_target = _TargetLowestAllyRanged(filter_skill_id=skills[slot].skill_id) if v_target == 0 and not targeting_strict: v_target = lowest_ally + elif target_allegiance == Skilltarget.AllyWithAdjacentEnemies: + v_target = TargetAllyWithMostAdjacentEnemies() elif target_allegiance == Skilltarget.OtherAlly: if skills[slot].custom_skill_data.Nature == SkillNature.EnergyBuff.value: v_target = _TargetLowestAllyEnergy(other_ally=True, filter_skill_id=skills[slot].skill_id) @@ -1563,6 +1565,8 @@ def get_lowest_ally(): v_target = Routines.Targeting.TargetLowestAllyRanged(filter_skill_id=self.skills[slot].skill_id) if v_target == 0 and not targeting_strict: v_target = get_lowest_ally() + elif target_allegiance == Skilltarget.AllyWithAdjacentEnemies: + v_target = TargetAllyWithMostAdjacentEnemies() elif target_allegiance == Skilltarget.OtherAlly: if self.skills[slot].custom_skill_data.Nature == SkillNature.EnergyBuff.value: v_target = Routines.Targeting.TargetLowestAllyEnergy(other_ally=True, filter_skill_id=self.skills[slot].skill_id) From 38e209589719df64767d1cee0c0e3c8da7853ba3 Mon Sep 17 00:00:00 2001 From: Wellwisher533 <308834484+Wellwisher533@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:05:09 -0700 Subject: [PATCH 2/2] Reuse clustered targeting for Ancestors' Rage --- Py4GWCoreLib/HeroAI/targeting.py | 38 ++---------- Py4GWCoreLib/routines_src/Targeting.py | 81 ++++++++++++++++++++------ 2 files changed, 70 insertions(+), 49 deletions(-) diff --git a/Py4GWCoreLib/HeroAI/targeting.py b/Py4GWCoreLib/HeroAI/targeting.py index c75b106d1..91c7ddb7b 100644 --- a/Py4GWCoreLib/HeroAI/targeting.py +++ b/Py4GWCoreLib/HeroAI/targeting.py @@ -140,39 +140,13 @@ def TargetAllyWithMostAdjacentEnemies( and Utils.Distance(Agent.GetXY(agent_id), player_xy) <= distance ] - enemy_ids = Routines.Agents.GetFilteredEnemyArray( - player_xy[0], - player_xy[1], - distance + adjacent_range, + return Routines.Targeting.PickClusteredTarget( + adjacent_range, + filter_radius=distance, + candidate_agent_ids=candidates, + min_enemy_targets=min_enemies, + candidate_is_enemy=False, ) - enemy_ids = [ - enemy_id - for enemy_id in enemy_ids or [] - if Agent.IsValid(enemy_id) and Agent.IsAlive(enemy_id) - ] - - scored: list[tuple[int, float, int]] = [] - for agent_id in candidates: - target_xy = Agent.GetXY(agent_id) - enemy_count = sum( - 1 - for enemy_id in enemy_ids - if Utils.Distance(target_xy, Agent.GetXY(enemy_id)) <= adjacent_range - ) - if enemy_count < min_enemies: - continue - scored.append( - ( - -enemy_count, - Utils.Distance(target_xy, player_xy), - agent_id, - ) - ) - - if not scored: - return 0 - scored.sort() - return scored[0][2] def TargetMinionOrAllyNonEnchanted(filter_skill_id=0, distance=Range.Spellcast.value): diff --git a/Py4GWCoreLib/routines_src/Targeting.py b/Py4GWCoreLib/routines_src/Targeting.py index 934b25838..7a047b744 100644 --- a/Py4GWCoreLib/routines_src/Targeting.py +++ b/Py4GWCoreLib/routines_src/Targeting.py @@ -667,9 +667,17 @@ def PickClusteredEnemiesAroundCorpse( return best_corpse_id @staticmethod - def CountNearbyEnemies(agent_id, cluster_radius, *, cached_data=None): + def CountNearbyEnemies( + agent_id, + cluster_radius, + *, + cached_data=None, + exclude_center=True, + ): """Count alive enemies within ``cluster_radius`` of ``agent_id``, - excluding ``agent_id`` itself. Returns 0 for invalid inputs. + excluding ``agent_id`` itself by default. Set ``exclude_center=False`` + when the center is an ally, corpse, or other non-enemy anchor and every + nearby enemy should count. Returns 0 for invalid inputs. ``cached_data`` follows the same convention as the rest of the Targeting helpers - caller passes HeroAI's live cache when @@ -690,7 +698,8 @@ def CountNearbyEnemies(agent_id, cluster_radius, *, cached_data=None): nearby, lambda nid: Agent.IsValid(nid) and not Agent.IsDead(nid), ) - return max(0, len(nearby) - 1) + center_adjustment = 1 if exclude_center else 0 + return max(0, len(nearby) - center_adjustment) @staticmethod def PickClusteredTarget( @@ -699,15 +708,22 @@ def PickClusteredTarget( *, filter_radius=None, cached_data=None, + candidate_agent_ids=None, + min_enemy_targets=0, + candidate_is_enemy=True, ): - """Pick the enemy with the most direct neighbors within - ``cluster_radius``. + """Pick the candidate with the most enemy targets in range. + + The default candidate pool is enemies within ``filter_radius`` + (defaults to ``cluster_radius``) of the player, preserving the original + enemy-cluster behavior. Callers may instead supply explicit candidates, + such as allied creatures for an ally-centered point-blank effect. - Candidate pool is enemies within ``filter_radius`` (defaults to - ``cluster_radius``) of the player. When ``preferred_condition`` is - provided, candidates are restricted to matches; if none match, - returns 0 so the caller can drive its own fallback. Ties broken - by distance to player. + ``candidate_is_enemy`` controls whether the center itself is excluded + from the enemy count. ``min_enemy_targets`` applies after that choice. + When ``preferred_condition`` is provided, candidates are restricted to + matches; if none match, returns 0 so the caller can drive its own + fallback. Ties are broken by distance to the player. Suited to AoE-on-cast skills where the effect applies in a single radius around the cast target (e.g. Panic, Painful Bond, Cry of @@ -729,22 +745,53 @@ def PickClusteredTarget( effective_filter_radius = float(filter_radius if filter_radius is not None else cluster_radius) player_pos = Player.GetXY() - enemy_array = AgentArray.GetEnemyArray() - enemy_array = AgentArray.Filter.ByDistance(enemy_array, player_pos, effective_filter_radius) - enemy_array = AgentArray.Filter.ByCondition(enemy_array, lambda agent_id: Agent.IsAlive(agent_id)) - if not enemy_array: + candidates = ( + list(candidate_agent_ids or []) + if candidate_agent_ids is not None + else AgentArray.GetEnemyArray() + ) + candidates = AgentArray.Filter.ByDistance( + candidates, + player_pos, + effective_filter_radius, + ) + candidates = AgentArray.Filter.ByCondition( + candidates, + lambda agent_id: Agent.IsValid(agent_id) and Agent.IsAlive(agent_id), + ) + if not candidates: return 0 - candidates = enemy_array if preferred_condition is not None: - candidates = [agent_id for agent_id in enemy_array if preferred_condition(agent_id)] + candidates = [ + agent_id + for agent_id in candidates + if preferred_condition(agent_id) + ] if not candidates: return 0 + candidate_scores = { + candidate_id: Targeting.CountNearbyEnemies( + candidate_id, + cluster_radius, + cached_data=cached_data, + exclude_center=candidate_is_enemy, + ) + for candidate_id in candidates + } + candidates = [ + candidate_id + for candidate_id in candidates + if candidate_scores[candidate_id] >= max(0, int(min_enemy_targets)) + ] + if not candidates: + return 0 + scored = sorted( candidates, key=lambda c: ( - -Targeting.CountNearbyEnemies(c, cluster_radius, cached_data=cached_data), + -candidate_scores[c], Utils.Distance(player_pos, Agent.GetXY(c)), ), )