Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Py4GWCoreLib/Agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] = {}
Expand Down
3 changes: 3 additions & 0 deletions Py4GWCoreLib/BuildMgr.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ def ResolveAllyTarget(
TargetLowestAllyMartial,
TargetLowestAllyMelee,
TargetLowestAllyRanged,
TargetAllyWithMostAdjacentEnemies,
TargetAllyNonEnchanted,
TargetAllyNonWeaponSpelled,
TargetMinionNonEnchanted,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion Py4GWCoreLib/HeroAI/combat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion Py4GWCoreLib/HeroAI/custom_skill_src/ritualist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
40 changes: 40 additions & 0 deletions Py4GWCoreLib/HeroAI/targeting.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,46 @@ 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
]

return Routines.Targeting.PickClusteredTarget(
adjacent_range,
filter_radius=distance,
candidate_agent_ids=candidates,
min_enemy_targets=min_enemies,
candidate_is_enemy=False,
)


def TargetMinionOrAllyNonEnchanted(filter_skill_id=0, distance=Range.Spellcast.value):
minion_array = AgentArray.GetMinionArray()
minion_array = AgentArray.Filter.ByDistance(minion_array, Player.GetXY(), distance)
Expand Down
1 change: 1 addition & 0 deletions Py4GWCoreLib/HeroAI/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class Skilltarget (IntEnum):
ResurrectionAlly = 40
HexedAlly = 41
EnemyNotNearby = 42
AllyWithAdjacentEnemies = 43



Expand Down
6 changes: 5 additions & 1 deletion Py4GWCoreLib/SkillManager.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
81 changes: 64 additions & 17 deletions Py4GWCoreLib/routines_src/Targeting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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)),
),
)
Expand Down