diff --git a/control/config.txt b/control/config.txt index 1967baae90..04775eaf47 100644 --- a/control/config.txt +++ b/control/config.txt @@ -245,7 +245,10 @@ EdenPortalExit # Maximum walking path distance (client setting). Default: 17 # This corresponds to max_walk_path in server configuration -maxWalkPathDistance 17 +maxUnobstructedWalkPathDistance 17 + +# If there are obstacles and the path is walkable the max dist acceptable is 14 +maxObstructedWalkPathDistance 14 runFromTarget 0 runFromTarget_inAdvance 0 @@ -318,6 +321,7 @@ teleportAuto_atkMiss 10 teleportAuto_unstuck 0 teleportAuto_lostTarget 0 teleportAuto_dropTarget 0 +teleportAuto_dropTargetEngaged 1 teleportAuto_dropTargetKS 0 teleportAuto_dropTargetHidden 0 teleportAuto_attackedWhenSitting 0 @@ -441,16 +445,15 @@ mercenary_attackChangeTarget 1 mercenary_attack_dance_melee 0 mercenary_attack_dance_ranged 0 -mercenary_attackBeyondMaxDistance_waitForAgressive 0 +mercenary_attackBeyondMaxDistance_waitForAgressive 1 mercenary_attackBeyondMaxDistance_sendAttackWhileWaiting 0 mercenary_attackSendAttackWithMove 0 mercenary_attackWaitApproachFinish 1 -mercenary_lost_teleportToMaster_maxTries 6 -mercenary_route_randomWalk_rescueWhenLost 0 -mercenary_route_randomWalk_stopDuringAttack 0 -mercenary_route_randomWalk_waitMinDistance 0 +mercenary_route_rescueWhenLost 0 +mercenary_route_stopDuringAttack 0 +mercenary_route_waitMinDistance 10 mercenary_runFromTarget 0 mercenary_runFromTarget_inAdvance 0 @@ -462,8 +465,9 @@ mercenary_runFromTarget_noAttackMethodFallback_attackMaxDist 14 mercenary_runFromTarget_noAttackMethodFallback_minStep 8 mercenary_idleWalkType 1 +mercenary_followMode 1 mercenary_followDistanceMin 3 -mercenary_followDistanceMax 12 +mercenary_followDistanceMax 10 mercenary_moveNearWhenIdle 1 mercenary_moveNearWhenIdle_minDistance 3 @@ -480,6 +484,7 @@ mercenary_teleportAuto_maxDmgInLock 0 mercenary_teleportAuto_deadly 1 mercenary_teleportAuto_unstuck 0 mercenary_teleportAuto_dropTarget 0 +mercenary_teleportAuto_dropTargetEngaged 1 mercenary_teleportAuto_dropTargetKS 0 mercenary_teleportAuto_totalDmg 0 mercenary_teleportAuto_totalDmgInLock 0 @@ -511,16 +516,15 @@ homunculus_attackNoGiveup 0 homunculus_attackChangeTarget 1 homunculus_attack_dance_melee 0 -homunculus_attackBeyondMaxDistance_waitForAgressive 0 +homunculus_attackBeyondMaxDistance_waitForAgressive 1 homunculus_attackBeyondMaxDistance_sendAttackWhileWaiting 0 homunculus_attackSendAttackWithMove 0 homunculus_attackWaitApproachFinish 1 -homunculus_lost_teleportToMaster_maxTries 6 -homunculus_route_randomWalk_rescueWhenLost 0 -homunculus_route_randomWalk_stopDuringAttack 0 -homunculus_route_randomWalk_waitMinDistance 0 +homunculus_route_rescueWhenLost 0 +homunculus_route_stopDuringAttack 0 +homunculus_route_waitMinDistance 0 homunculus_runFromTarget 0 homunculus_runFromTarget_dist 5 @@ -531,8 +535,9 @@ homunculus_runFromTarget_noAttackMethodFallback_attackMaxDist 14 homunculus_runFromTarget_noAttackMethodFallback_minStep 8 homunculus_idleWalkType 1 +homunculus_followMode 1 homunculus_followDistanceMin 3 -homunculus_followDistanceMax 12 +homunculus_followDistanceMax 10 homunculus_moveNearWhenIdle 1 homunculus_moveNearWhenIdle_minDistance 3 @@ -550,6 +555,7 @@ homunculus_teleportAuto_maxDmgInLock 0 homunculus_teleportAuto_deadly 1 homunculus_teleportAuto_unstuck 0 homunculus_teleportAuto_dropTarget 0 +homunculus_teleportAuto_dropTargetEngaged 1 homunculus_teleportAuto_dropTargetKS 0 homunculus_teleportAuto_totalDmg 0 homunculus_teleportAuto_totalDmgInLock 0 diff --git a/control/timeouts.txt b/control/timeouts.txt index 6b8294d666..7adfc04328 100644 --- a/control/timeouts.txt +++ b/control/timeouts.txt @@ -29,8 +29,8 @@ ai 1 ai_move_retry 0.4 ai_move_giveup 1.2 -ai_homunculus_standby 0.5 -ai_mercenary_standby 0.5 +ai_homunculus_standby 2 +ai_mercenary_standby 2 # Send the attack packet every x seconds, if it hasn't been send already ai_attack 1 @@ -49,9 +49,6 @@ ai_attack_auto 0.5 ai_homunculus_attack_auto 0.5 ai_mercenary_attack_auto 0.5 -ai_attack_route_adjust 0.15 -ai_homunculus_route_adjust 0.15 -ai_mercenary_route_adjust 0.15 # Ignore monster for x seconds if there is no route to it ai_attack_failedLOS 12 @@ -74,7 +71,7 @@ ai_attack_main 0.1 ai_homunculus_attack_main 0.1 ai_mercenary_attack_main 0.1 -ai_future_reachability_lookup 0.3 +ai_future_reachability_lookup 0.7 ai_attack_unstuck 2.75 ai_attack_unfail 12 @@ -222,4 +219,6 @@ ai_clientSuspend 1 ai_route_position_prediction_delay 0.05 meetingPosition_future_reachability_lookup 0.3 -ai_attack_allowed_waitForTarget 0.3 \ No newline at end of file +# If a target is predicted to move into attack range/LOS soon, wait up to x seconds +# before rerouting or dropping it. Shared by player and slave attack AI. +ai_attack_allowed_waitForTarget 0.3 diff --git a/plugins/avoidAssisters/avoidAssisters.pl b/plugins/avoidAssisters/avoidAssisters.pl index 78b49b66be..d36800af08 100644 --- a/plugins/avoidAssisters/avoidAssisters.pl +++ b/plugins/avoidAssisters/avoidAssisters.pl @@ -8,50 +8,187 @@ # target and blocks it when there are too many nearby "assister" monsters. # # It supports two configuration modes: -# 1. avoidAssisters_N +# 1. avoidAssisters { ... } # Applies the check only when the current target matches the configured mob ID. -# 2. avoidGlobalAssisters_N +# 2. avoidGlobalAssisters { ... } # Applies the check to any target when a configured assister mob is nearby. # # How to configure it: -# Add entries in config.txt using a numeric index (0, 1, 2, ...). +# Add blocks in config.txt using one or more +# `maxAssistersBellowDistAllowed ` lines. # # Per-target assister check: -# avoidAssisters_0 1 -# avoidAssisters_0_id 1096 -# avoidAssisters_0_checkRange 9 -# avoidAssisters_0_maxMobsInRange 2 +# avoidAssisters 1096 { +# maxAssistersBellowDistAllowed 9 2 +# } # # Global assister check: -# avoidGlobalAssisters_0 1 -# avoidGlobalAssisters_0_id 1113 -# avoidGlobalAssisters_0_checkRange 9 -# avoidGlobalAssisters_0_maxMobsInRange 3 +# avoidGlobalAssisters 1113 { +# maxAssistersBellowDistAllowed 9 3 +# } # # Meaning of each field: -# - *_id: Monster ID to watch. -# - *_checkRange: Distance around the target to scan for assisting mobs. -# - *_maxMobsInRange: Maximum allowed assisting mobs before the target is dropped. +# - Block value: Monster ID to watch. +# - maxAssistersBellowDistAllowed : +# Distance around the target to scan plus the maximum allowed assisting mobs +# inside that distance before the target is dropped. # # Examples: # 1. Avoid attacking a mob if there are more than 2 monsters of the same type # close enough to assist it: -# avoidAssisters_0 1 -# avoidAssisters_0_id 1096 -# avoidAssisters_0_checkRange 9 -# avoidAssisters_0_maxMobsInRange 2 +# avoidAssisters 1096 { +# maxAssistersBellowDistAllowed 9 2 +# } # # 2. Avoid any target if there are more than 3 dangerous support mobs nearby: -# avoidGlobalAssisters_0 1 -# avoidGlobalAssisters_0_id 1113 -# avoidGlobalAssisters_0_checkRange 9 -# avoidGlobalAssisters_0_maxMobsInRange 3 +# avoidGlobalAssisters 1113 { +# maxAssistersBellowDistAllowed 9 3 +# } # -# 3. Use multiple rules by increasing the index: -# avoidAssisters_1 1 -# avoidAssisters_1_id 1155 -# avoidAssisters_1_checkRange 7 -# avoidAssisters_1_maxMobsInRange 1 +# 3. Use multiple thresholds in the same block: +# avoidGlobalAssisters 1368 { +# maxAssistersBellowDistAllowed 5 0 +# maxAssistersBellowDistAllowed 9 1 +# } +# +# Detailed simulation: target is NOT picked +# ---------------------------------------- +# Imagine the bot is evaluating a Spore (nameID 1014, binID 2) during +# `getBestTarget`, before we have attacked it even once. +# +# Config: +# avoidGlobalAssisters 1368 { +# maxAssistersBellowDistAllowed 5 0 +# maxAssistersBellowDistAllowed 9 1 +# } +# +# Visible actors: +# - Candidate target Spore (binID 2) at pos_to (115, 113) +# - Geographer (binID 0, nameID 1368) at pos_to (112, 111) +# - Geographer (binID 5, nameID 1368) at pos_to (108, 107) +# - The Geographers are free mobs, so they count as possible assisters. +# +# Step 1: +# `getBestTarget` asks this plugin whether the candidate should be filtered out. +# +# Step 2: +# `get_target_positions()` gathers the target positions to test. +# Before engagement, we are strict and allow a single bad checked position to +# reject the target. +# Example checked positions: +# - calc_pos = (115, 113) +# - pos_to = (115, 113) +# If both are equal, only one effective position is checked. +# +# Step 3: +# `find_assister_drop_reason_for_position()` evaluates each configured rule +# against that target position. +# +# Rule A: +# `maxAssistersBellowDistAllowed 5 0` +# - Count Geographers within 5 cells of the target. +# - Geographer at (112, 111) is within range. +# - Count = 1 +# - Allowed = 0 +# - Result: FAIL, because 1 > 0 +# +# Rule B would not even need to run after Rule A already failed for that tested +# position, because the plugin returns the first blocking reason it finds. +# +# Step 4: +# Because this happened inside `getBestTarget`, and we have not engaged the +# target yet, the plugin immediately filters the Spore out of +# `possibleTargets`. +# +# Final decision: +# - The target is NOT picked. +# - Reason: at least one configured assister rule failed for at least one checked +# target position before combat started. +# +# Why this is intentionally strict: +# - Before the bot commits to a target, it is cheap to discard a risky pull. +# - After the bot has already engaged the target, `shouldDropTarget` is more +# conservative and may require both `calc_pos` and `pos_to` to fail before it +# abandons the fight. +# +# Detailed simulation: target is DROPPED mid attack route +# ------------------------------------------------------- +# Imagine the bot already picked a Spore and has started attacking it, so the +# target is now being checked by `shouldDropTarget` while we are routing or +# re-evaluating during combat. +# +# Config: +# avoidGlobalAssisters 1368 { +# maxAssistersBellowDistAllowed 5 0 +# maxAssistersBellowDistAllowed 9 1 +# } +# +# Current combat state: +# - We already committed to the Spore, so `$target->{sentAttack}` or +# `$target->{engaged}` is set. +# - That means the target counts as "engaged by us". +# +# Visible actors: +# - Current target Spore (binID 2) +# - Geographer (binID 0, nameID 1368) at pos_to (112, 111) +# - Geographer (binID 5, nameID 1368) at pos_to (109, 108) +# +# Step 1: +# `shouldDropTarget` calls `should_drop_target_from_assisters()`. +# +# Step 2: +# `get_target_positions()` gathers both target positions because they differ: +# - calc_pos = (115, 113) +# - pos_to = (114, 112) +# +# Step 3: +# The plugin checks whether this target has already been engaged by us. +# Because combat already started, the plugin becomes more conservative: +# it will only drop if every checked target position is bad. +# +# Step 4: +# `find_assister_drop_reason_for_position()` evaluates `calc_pos`. +# Rule A: +# `maxAssistersBellowDistAllowed 5 0` +# - Geographer at (112, 111) is within 5 cells +# - Count = 1 +# - Allowed = 0 +# - Result: FAIL for `calc_pos` +# +# Step 5: +# `find_assister_drop_reason_for_position()` evaluates `pos_to`. +# Rule A again: +# - Geographer at (112, 111) is also within 5 cells of (114, 112) +# - Count = 1 +# - Allowed = 0 +# - Result: FAIL for `pos_to` +# +# Step 6: +# Because the target was already engaged, the plugin now asks: +# "Did every checked target position fail?" +# In this example: +# - calc_pos failed +# - pos_to failed +# So the answer is YES. +# +# Step 7: +# Because the target was already engaged, the plugin starts a short +# confirmation timer instead of dropping immediately. +# - If the target stops qualifying during that grace window, the timer is reset. +# - If it is still unsafe after the configured delay, the target is dropped. +# +# Final decision: +# - The target IS dropped mid-route / mid-fight only if it remained unsafe for +# the whole confirmation delay. +# - Reason: after engagement, both checked target positions were still inside +# assister-danger conditions long enough that the plugin concluded the fight +# remained unsafe. +# +# Why this is intentionally more conservative than `getBestTarget`: +# - Once combat already started, a single stale position should not be enough to +# abandon the target. +# - Requiring both `calc_pos` and `pos_to` to fail reduces false-positive drops +# caused by brief movement prediction mismatch. # # Notes: # - The plugin ignores monsters already fighting a player. @@ -61,6 +198,7 @@ package avoidAssisters; use strict; +use Time::HiRes qw(time); use Globals; use Settings; use Misc; @@ -68,171 +206,1107 @@ package avoidAssisters; use Utils; use Log qw(message debug error warning); +use constant ENGAGED_DROP_CONFIRMATION_DELAY => 0.5; +use constant ASSISTER_DROP_RELEASE_COOLDOWN => 1.0; +use constant RELEASE_VISIBILITY_MARGIN => 2; + Plugins::register('avoidAssisters', 'enable custom conditions', \&onUnload); -my %check_avoidAssisters_exists_hash; my @avoidAssisters_mobs; +my %avoidAssisters_rules_by_target_nameID; -my %check_avoidGlobalAssisters_exists_hash; my @avoidGlobalAssisters_mobs; +my %visible_monster_count_by_nameID; +my %visible_monsters_by_nameID; my $hooks = Plugins::addHooks( # Setup ['post_configModify', \&onpost_configModify, undef], - ['pos_load_config.txt', \&onpost_configModify, undef], + ['post_bulkConfigModify', \&onpost_bulkConfigModify, undef], + ['pos_load_config.txt', \&reload_config, undef], + ['packet_mapChange', \&on_packet_mapChange, undef], + + # Visible monster cache + ['add_monster_list', \&on_add_monster_list, undef], + ['monster_disappeared', \&on_monster_disappeared, undef], # Target check - ['AI::Attack::process', \&on_AIAttackprocess, undef], - ['getBestTarget', \&on_AIAttackprocess, undef], + ['shouldDropTarget', \&on_shouldDropTarget, undef], + ['getBestTarget', \&on_getBestTarget, undef], +); + +my $chooks = Commands::register( + ['assist', 'avoidAssisters controls: assist [conf|dump|status]', \&command_avoidAssisters], ); +reload_config(); + +## Purpose: Unregisters every plugin hook when the plugin is unloaded. +## Args: none. +## Returns: nothing. +## Notes: This exists so reloading or disabling the plugin does not leave stale +## hook callbacks active in the OpenKore runtime. sub onUnload { Plugins::delHooks($hooks); + Commands::unregister($chooks) if $chooks; } -sub onpost_configModify { - undef %check_avoidAssisters_exists_hash; +## Purpose: Rebuilds the plugin's in-memory rule tables from config.txt. +## Args: none. +## Returns: nothing. +## Notes: This is the shared reload entry point used by initial config load and +## runtime config update hooks so parsing behavior stays centralized. +sub reload_config { undef @avoidAssisters_mobs; - - undef %check_avoidGlobalAssisters_exists_hash; + undef %avoidAssisters_rules_by_target_nameID; undef @avoidGlobalAssisters_mobs; - parse_avoidAssisters(); - parse_avoidGlobalAssisters(); + my $parsed_blocks = read_avoid_assisters_blocks_from_config_file(); + parse_avoidAssisters($parsed_blocks->{avoidAssisters}); + parse_avoidGlobalAssisters($parsed_blocks->{avoidGlobalAssisters}); + rebuild_visible_monster_count_cache(); +} + +## Purpose: Reads avoidAssisters blocks directly from the current config file. +## Args: none. +## Returns: A hashref with `avoidAssisters` and `avoidGlobalAssisters` block arrays. +## Notes: This parser exists so the plugin can support repeated keys inside one +## config block, such as multiple `maxAssistersBellowDistAllowed` entries. +sub read_avoid_assisters_blocks_from_config_file { + my %blocks = ( + avoidAssisters => [], + avoidGlobalAssisters => [], + ); + + my $filename = Settings::getConfigFilename(); + return \%blocks unless defined $filename && $filename ne '' && -f $filename; + + open my $fh, '<:utf8', $filename or do { + warning "[avoidAssisters] Unable to open config file '$filename' for parsing: $!\n"; + return \%blocks; + }; + + my $current_block; + my $block_index = 0; + my $line_number = 0; + while (my $line = <$fh>) { + $line_number++; + chomp $line; + $line =~ s/\r$//; + $line =~ s/\s+#.*$//; + next if $line =~ /^\s*$/; + + if (!$current_block) { + if ($line =~ /^\s*(avoidAssisters|avoidGlobalAssisters)(?:\s+([^\{]+?))?\s*\{\s*$/) { + $current_block = { + type => $1, + identifier_raw => defined $2 ? $2 : '', + attributes => [], + index => $block_index++, + line => $line_number, + }; + } + next; + } + + if ($line =~ /^\s*\}\s*$/) { + push @{ $blocks{$current_block->{type}} }, $current_block; + undef $current_block; + next; + } + + if ($line =~ /^\s*([A-Za-z_]\w*)(?:\s+(.*?))?\s*$/) { + push @{ $current_block->{attributes} }, { + key => $1, + value => defined $2 ? $2 : '', + line => $line_number, + }; + next; + } + } + + close $fh; + return \%blocks; +} + +## Purpose: Normalizes one numeric assister config value. +## Args: `($value, $config_key)` where `$value` is the raw config scalar and +## `$config_key` is the source key name used in warning output. +## Returns: The numeric value, or `undef` when the input is missing or invalid. +## Notes: This keeps parse-time validation centralized so malformed assister +## blocks can be skipped cleanly with one consistent warning format. +sub normalize_assister_numeric_config_value { + my ($value, $config_key) = @_; + return unless defined $value && $value ne ''; + + if ($value =~ /^-?\d+$/) { + return 0 + $value; + } + + warning "[avoidAssisters] Ignoring invalid numeric value '$value' for $config_key.\n"; + return; +} + +## Purpose: Rebuilds the visible monster count cache when the map changes. +## Args: Hook arguments are ignored. +## Returns: nothing. +## Notes: Monster add/disappear hooks keep the cache live incrementally, but a map +## change invalidates the whole visible actor set at once, so the cache is reset +## and allowed to repopulate from fresh add-monster events on the new map. +sub on_packet_mapChange { + reset_visible_monster_cache(); +} + +## Purpose: Reacts to a single config modification that may affect this plugin. +## Args: `(undef, $args)` from `post_configModify`, where `$args->{key}` is the +## changed config key and `$args->{bulk}` marks bulk updates. +## Returns: nothing. +## Notes: This exists to avoid reparsing on unrelated config changes and to defer +## bulk updates to `onpost_bulkConfigModify`. +sub onpost_configModify { + my (undef, $args) = @_; + return if $args && $args->{bulk}; + return if $args && !is_plugin_config_key($args->{key}); + reload_config(); +} + +## Purpose: Reacts once after a bulk config update finishes. +## Args: `(undef, $args)` from `post_bulkConfigModify`, where `$args->{keys}` is +## the hashref of modified config keys. +## Returns: nothing. +## Notes: Bulk config operations may touch several plugin keys at once, so this +## hook reloads exactly once after the batch instead of once per key. +sub onpost_bulkConfigModify { + my (undef, $args) = @_; + return unless $args && bulk_includes_plugin_config_keys($args->{keys}); + reload_config(); +} + +## Purpose: Checks whether a single config key belongs to avoidAssisters. +## Args: `($key)` where `$key` is a config.txt key name. +## Returns: `1` if the key belongs to this plugin, otherwise `0`. +## Notes: This helper keeps the config hooks from reloading on unrelated config +## changes elsewhere in the bot configuration. +sub is_plugin_config_key { + my ($key) = @_; + return 0 unless defined $key && $key ne ''; + return 1 if $key =~ /^avoidAssisters(?:_|$)/; + return 1 if $key =~ /^avoidGlobalAssisters(?:_|$)/; + return 0; } +## Purpose: Detects whether a bulk config change touched any plugin key. +## Args: `($keys)` where `$keys` is the hashref provided by the bulk config hook. +## Returns: `1` if at least one changed key belongs to this plugin, otherwise `0`. +## Notes: This is the bulk-update counterpart to `is_plugin_config_key`. +sub bulk_includes_plugin_config_keys { + my ($keys) = @_; + return 0 unless $keys; + foreach my $key (keys %{$keys}) { + return 1 if is_plugin_config_key($key); + } + + return 0; +} + + +## Purpose: Parses all per-target avoidAssisters rules from config.txt. +## Args: `($blocks)` where `$blocks` is the parsed avoidAssisters block arrayref. +## Returns: nothing. +## Notes: Each block expands into one or more normalized rules and fills both the +## flat rule list and a per-target lookup table so runtime checks can grab +## matching rules in O(1). sub parse_avoidAssisters { - my $i = 0; - while (exists $config{"avoidAssisters_$i"}) { - next unless (defined $config{"avoidAssisters_$i"}); - next unless (defined $config{"avoidAssisters_$i"."_id"}); - next unless (defined $config{"avoidAssisters_$i"."_checkRange"}); - next unless (defined $config{"avoidAssisters_$i"."_maxMobsInRange"}); + my ($blocks) = @_; + return unless $blocks; - my %mobAvoid; - $mobAvoid{id} = $config{"avoidAssisters_$i"."_id"}; - $mobAvoid{checkRange} = $config{"avoidAssisters_$i"."_checkRange"}; - $mobAvoid{maxMobsInRange} = $config{"avoidAssisters_$i"."_maxMobsInRange"}; + foreach my $block (@{$blocks}) { + my $target_id = get_assister_block_target_id($block); + next unless defined $target_id; - push(@avoidAssisters_mobs, \%mobAvoid); - $check_avoidAssisters_exists_hash{$mobAvoid{id}} = 1; + foreach my $rule_data (@{ expand_assister_block_rules($block) }) { + my %mobAvoid = ( + id => $target_id, + checkRange => $rule_data->{checkRange}, + maxMobsInRange => $rule_data->{maxMobsInRange}, + blockKey => get_assister_block_display_key($block), + blockValue => $target_id, + ruleScope => 'avoidAssisters', + ruleKey => $rule_data->{ruleKey}, + ruleValue => $rule_data->{ruleValue}, + ); - } continue { - $i++; + push(@avoidAssisters_mobs, \%mobAvoid); + push @{ $avoidAssisters_rules_by_target_nameID{$mobAvoid{id}} }, \%mobAvoid; + } } } +## Purpose: Parses all global assister rules from config.txt. +## Args: `($blocks)` where `$blocks` is the parsed avoidGlobalAssisters block arrayref. +## Returns: nothing. +## Notes: These rules apply to any target and may expand one config block into +## several normalized assister thresholds. sub parse_avoidGlobalAssisters { - my $i = 0; - while (exists $config{"avoidGlobalAssisters_$i"}) { - next unless (defined $config{"avoidGlobalAssisters_$i"}); - next unless (defined $config{"avoidGlobalAssisters_$i"."_id"}); - next unless (defined $config{"avoidGlobalAssisters_$i"."_checkRange"}); - next unless (defined $config{"avoidGlobalAssisters_$i"."_maxMobsInRange"}); + my ($blocks) = @_; + return unless $blocks; - my %mobAvoid; - $mobAvoid{id} = $config{"avoidGlobalAssisters_$i"."_id"}; - $mobAvoid{checkRange} = $config{"avoidGlobalAssisters_$i"."_checkRange"}; - $mobAvoid{maxMobsInRange} = $config{"avoidGlobalAssisters_$i"."_maxMobsInRange"}; + foreach my $block (@{$blocks}) { + my $assister_id = get_assister_block_target_id($block); + next unless defined $assister_id; - push(@avoidGlobalAssisters_mobs, \%mobAvoid); - $check_avoidGlobalAssisters_exists_hash{$mobAvoid{id}} = 1; + foreach my $rule_data (@{ expand_assister_block_rules($block) }) { + my %mobAvoid = ( + id => $assister_id, + checkRange => $rule_data->{checkRange}, + maxMobsInRange => $rule_data->{maxMobsInRange}, + blockKey => get_assister_block_display_key($block), + blockValue => $assister_id, + ruleScope => 'avoidGlobalAssisters', + ruleKey => $rule_data->{ruleKey}, + ruleValue => $rule_data->{ruleValue}, + ); - } continue { - $i++; + push(@avoidGlobalAssisters_mobs, \%mobAvoid); + } } } -# This call is done in AI::Attack::process and is used to drop targets before the attack proceeds, drops when return value is 1 -sub on_AIAttackprocess { - my ($hook, $args) = @_; +## Purpose: Builds a readable identifier for one parsed config block. +## Args: `($block)` where `$block` is the parsed block hashref. +## Returns: A readable block identifier string. +## Notes: This keeps dumps and warnings stable even when one block expands into +## multiple normalized rules. +sub get_assister_block_display_key { + my ($block) = @_; + return 'unknownBlock' unless $block; + return sprintf('%s[%d]', $block->{type}, $block->{index}); +} - my $target = $args->{target}; - my $mob_id = $target->{nameID}; - - my $targetPos = calcPosFromPathfinding($field, $target); +## Purpose: Resolves the target or assister mob ID for one config block. +## Args: `($block)` where `$block` is the parsed block hashref. +## Returns: The normalized numeric mob ID, or `undef` when missing. +## Notes: The block header value is preferred, but legacy inner `id` entries are +## still accepted for backward compatibility. +sub get_assister_block_target_id { + my ($block) = @_; + return unless $block; - my $is_dropped = isTargetDroppedAssisters($target); - - #return if ($args->{target_is_aggressive}); - - my $drop_string; - if ($hook eq 'AI::Attack::process') { - $drop_string = 'Dropping'; - } elsif ($hook eq 'getBestTarget') { - $drop_string = 'Not picking'; - } - - if (exists $check_avoidAssisters_exists_hash{$mob_id}) { - foreach my $avoidAssister_mob (@avoidAssisters_mobs) { - next unless ($avoidAssister_mob->{id} == $mob_id); - - my $count = 0; - for my $monster (@$monstersList) { - next if ($monster->{ID} eq $target->{ID}); - next unless ($monster->{nameID} == $mob_id); - next if (isMobFightingPlayer($monster)); - next if (blockDistance($monster->{pos_to}, $targetPos) > $avoidAssister_mob->{checkRange}); - $count++; + my $raw_identifier = $block->{identifier_raw}; + if ((!defined $raw_identifier || $raw_identifier eq '') && $block->{attributes}) { + foreach my $attribute (@{ $block->{attributes} }) { + next unless $attribute->{key} eq 'id'; + $raw_identifier = $attribute->{value}; + last; + } + } + + my $config_key = get_assister_block_display_key($block) . '.id'; + return normalize_assister_numeric_config_value($raw_identifier, $config_key); +} + +## Purpose: Expands one parsed config block into normalized range/threshold rules. +## Args: `($block)` where `$block` is the parsed block hashref. +## Returns: An arrayref of normalized rule hashes. +## Notes: This supports repeated `maxAssistersBellowDistAllowed ` +## lines and rejects blocks that do not use the merged syntax. +sub expand_assister_block_rules { + my ($block) = @_; + return [] unless $block; + + my @rules; + my @merged_attrs = grep { + $_->{key} eq 'maxAssistersBellowDistAllowed' || $_->{key} eq 'maxAssistersBelowDistAllowed' + } @{ $block->{attributes} || [] }; + + if (@merged_attrs) { + my $merged_index = 0; + foreach my $attribute (@merged_attrs) { + my ($check_range_raw, $max_allowed_raw) = ($attribute->{value} || '') =~ /^\s*(-?\d+)\s+(-?\d+)\s*$/; + unless (defined $check_range_raw && defined $max_allowed_raw) { + warning "[avoidAssisters] Ignoring invalid $attribute->{key} value '$attribute->{value}' in " . get_assister_block_display_key($block) . ". Expected ' '.\n"; + $merged_index++; + next; } - - if ($count > $avoidAssister_mob->{maxMobsInRange}) { - warning "[avoidAssisters] [$hook] $drop_string target $target (ID: $target->{nameID}) because it has too many avoidAssisters (".$count.") in range (".$avoidAssister_mob->{checkRange}.") and the max allowed is ".$avoidAssister_mob->{maxMobsInRange}.".\n" if (!$is_dropped); - if ($hook eq 'AI::Attack::process') { - AI::dequeue while (AI::inQueue("attack")) - } - $target->{attackFailedAssisters} = 1; - $args->{return} = 1; - return; + my $rule_config_key_prefix = get_assister_block_display_key($block) . "." . $attribute->{key} . "[$merged_index]"; + my $check_range = normalize_assister_numeric_config_value($check_range_raw, $rule_config_key_prefix . ".checkRange"); + my $max_mobs_in_range = normalize_assister_numeric_config_value($max_allowed_raw, $rule_config_key_prefix . ".maxMobsInRange"); + if (defined $check_range && defined $max_mobs_in_range) { + push @rules, { + checkRange => $check_range, + maxMobsInRange => $max_mobs_in_range, + ruleKey => $attribute->{key} . "[$merged_index]", + ruleValue => $attribute->{value}, + }; } + $merged_index++; } + + return \@rules; } - - foreach my $avoid_global_mob (@avoidGlobalAssisters_mobs) { - my $count = 0; - foreach my $monster (@{$monstersList}) { - next if ($monster->{ID} eq $target->{ID}); - next unless ($monster->{nameID} == $avoid_global_mob->{id}); - next if (isMobFightingPlayer($monster)); - next if (blockDistance($monster->{pos_to}, $targetPos) > $avoid_global_mob->{checkRange}); - $count++; - } - - if ($count > $avoid_global_mob->{maxMobsInRange}) { - warning "[avoidAssisters] [$hook] $drop_string target $target (ID: $target->{nameID}) - Too many Global assisters near it (Mob ID $avoid_global_mob->{id} | count $count > $avoid_global_mob->{maxMobsInRange} | range $avoid_global_mob->{checkRange})\n" if (!$is_dropped); - if ($hook eq 'AI::Attack::process') { - AI::dequeue while (AI::inQueue("attack")) - } - $target->{attackFailedAssisters} = 1; - $args->{return} = 1; - return; - } - } - if ($is_dropped) { - my $myPos = calcPosFromPathfinding($field, $char); - my $monsterDist = blockDistance($myPos, $targetPos); + warning "[avoidAssisters] Ignoring " . get_assister_block_display_key($block) . " because it has no maxAssistersBellowDistAllowed entries.\n"; + return []; +} + +## Purpose: Clears the visible-monster caches for counts and buckets. +## Args: none. +## Returns: nothing. +## Notes: This is the shared reset path used before full cache rebuilds and on map +## changes when visible actor state must be discarded immediately. +sub reset_visible_monster_cache { + undef %visible_monster_count_by_nameID; + undef %visible_monsters_by_nameID; +} + +## Purpose: Rebuilds the visible-monster caches from the live monster list. +## Args: none. +## Returns: nothing. +## Notes: This is the authoritative reset path for the cache and is used after +## config reloads and map changes so incremental hook updates start from truth. +sub rebuild_visible_monster_count_cache { + reset_visible_monster_cache(); + return unless $monstersList; + + foreach my $monster (@{$monstersList}) { + next unless $monster; + increment_visible_monster_count($monster); + } +} + +## Purpose: Updates the visible-monster count cache when a monster appears. +## Args: `(undef, $monster)` from `add_monster_list`, where `$monster` is the live actor. +## Returns: nothing. +## Notes: This keeps the nameID cache current without needing to rescan the full +## monster list every time target selection runs. If a relevant assister was kept +## cached while off screen and later returns, the cached entry is refreshed +## without double-counting it. +sub on_add_monster_list { + my (undef, $monster) = @_; + increment_visible_monster_count($monster); +} + +## Purpose: Updates the visible-monster count cache when a monster disappears. +## Args: `(undef, $args)` from `monster_disappeared`, where `$args->{monster}` is +## the actor leaving the visible set. +## Returns: nothing. +## Notes: This is the inverse of `on_add_monster_list` and keeps the cached counts +## aligned with the currently visible monster set. When a relevant assister is +## truly removed on screen, such as by death or teleport, all currently blocked +## visible targets are rechecked immediately so they can be released without +## waiting for a later targeting pass. +sub on_monster_disappeared { + my (undef, $args) = @_; + my $monster = $args->{monster}; + decrement_visible_monster_count($monster) unless should_keep_disappeared_monster_in_assister_cache($monster); + recheck_all_dropped_targets_from_assisters() if is_truly_removed_relevant_assister($monster); +} + +## Purpose: Increments the cached visible count for one monster nameID. +## Args: `($monster)` where `$monster` is the live actor to account for. +## Returns: nothing. +## Notes: The helper silently ignores missing actors or actors without a defined +## `nameID` and `ID` so hook callbacks can stay tiny and safe. It updates both the +## total visible count and the per-nameID actor bucket, with the bucket acting as +## the source of truth to avoid double-counting duplicate add events. +sub increment_visible_monster_count { + my ($monster) = @_; + return unless $monster; + return unless defined $monster->{nameID}; + return unless defined $monster->{ID}; + + my $bucket = ($visible_monsters_by_nameID{$monster->{nameID}} ||= {}); + if (exists $bucket->{$monster->{ID}}) { + $bucket->{$monster->{ID}} = $monster; + return; + } - my $max_dist_to_release = ($config{clientSight} - 10); # 10 here because assit range is 9 and add 1 for the proper target pos + $bucket->{$monster->{ID}} = $monster; + $visible_monster_count_by_nameID{$monster->{nameID}}++; +} + +## Purpose: Decrements the cached visible count for one monster nameID. +## Args: `($monster)` where `$monster` is the actor being removed from visibility. +## Returns: nothing. +## Notes: Counts are clamped by deletion at zero so stale duplicate disappear +## events cannot drive the cache negative. The matching actor is also removed from +## the per-nameID bucket. +sub decrement_visible_monster_count { + my ($monster) = @_; + return unless $monster; + return unless defined $monster->{nameID}; + return unless defined $monster->{ID}; + return unless exists $visible_monsters_by_nameID{$monster->{nameID}}; + return unless exists $visible_monsters_by_nameID{$monster->{nameID}}{$monster->{ID}}; + + delete $visible_monsters_by_nameID{$monster->{nameID}}{$monster->{ID}}; + $visible_monster_count_by_nameID{$monster->{nameID}}--; + delete $visible_monster_count_by_nameID{$monster->{nameID}} if $visible_monster_count_by_nameID{$monster->{nameID}} <= 0; + delete $visible_monsters_by_nameID{$monster->{nameID}} if exists $visible_monsters_by_nameID{$monster->{nameID}} && !scalar keys %{ $visible_monsters_by_nameID{$monster->{nameID}} }; +} + +## Purpose: Reports whether a monster nameID participates in any assister rule. +## Args: `($name_id)` where `$name_id` is a monster nameID scalar. +## Returns: `1` when the nameID is referenced by any per-target or global rule. +## Notes: This keeps the disappearance-retention logic limited to monsters that +## can actually affect this plugin's decisions. +sub is_relevant_assister_nameID { + my ($name_id) = @_; + return 0 unless defined $name_id; + return 1 if exists $avoidAssisters_rules_by_target_nameID{$name_id}; + + foreach my $rule (@avoidGlobalAssisters_mobs) { + return 1 if defined $rule->{id} && $rule->{id} == $name_id; + } + + return 0; +} + +## Purpose: Decides whether a disappeared monster should stay cached as a hidden assister. +## Args: `($monster)` where `$monster` is the actor leaving visibility. +## Returns: `1` when the monster should remain cached, otherwise `0`. +## Notes: Plain `disappeared` means the monster only moved off screen, so relevant +## assisters are kept temporarily to avoid immediate release churn. True removals +## such as death or teleport are not kept. +sub should_keep_disappeared_monster_in_assister_cache { + my ($monster) = @_; + return 0 unless $monster; + return 0 if is_truly_removed_relevant_assister($monster); + return 0 unless ($monster->{disappeared} || 0) == 1; + return is_relevant_assister_nameID($monster->{nameID}); +} + +## Purpose: Reports whether a disappeared monster truly matters to assister rechecks. +## Args: `($monster)` where `$monster` is the actor that just left visibility. +## Returns: `1` when the actor was truly removed from the field by death or +## teleport and its nameID participates in any assister rule. +## Notes: `monster_disappeared` is also used for actors that merely moved off +## screen, so plain `$monster->{disappeared}` must not trigger the full recheck. +sub is_truly_removed_relevant_assister { + my ($monster) = @_; + return 0 unless $monster; + return 0 unless ($monster->{dead} || $monster->{teleported}); + return is_relevant_assister_nameID($monster->{nameID}); +} + +## Purpose: Re-evaluates every currently blocked visible monster after assister loss. +## Args: none. +## Returns: nothing. +## Notes: A dying assister can make nearby targets safe again immediately, so this +## helper proactively runs the plugin release logic for currently blocked visible +## monsters instead of waiting for the next ordinary target-selection pass. +sub recheck_all_dropped_targets_from_assisters { + return unless $monstersList; + + foreach my $target (@{$monstersList}) { + next unless $target; + next unless isTargetDroppedAssisters($target); + should_drop_target_from_assisters('monster_disappeared_recheck', $target, 'Rechecking'); + } +} + +## Purpose: Decides whether the current attack target should be dropped. +## Args: `($hook, $args)` from the `shouldDropTarget` hook, where `$args->{target}` +## is the live target actor and `$args->{return}` is the hook's decision flag. +## Returns: nothing directly; sets `$args->{return}` to `1` when the target must be dropped. +## Notes: This is the runtime safety gate for targets we are already attacking or +## routing toward. +sub on_shouldDropTarget { + my ($hook, $args) = @_; + return unless $field; + return unless $args->{target}; + + if (should_drop_target_from_assisters($hook, $args->{target}, 'Dropping')) { + $args->{return} = 1; + } +} + +## Purpose: Filters blocked targets out of the target-selection candidate list. +## Args: `($hook, $args)` from the `getBestTarget` hook, where +## `$args->{possibleTargets}` is an arrayref of monster IDs. +## Returns: nothing directly; mutates `$args->{possibleTargets}` in place. +## Notes: This exists so bad targets are removed before OpenKore scores and picks +## a best target. +sub on_getBestTarget { + my ($hook, $args) = @_; + return unless $field; + return unless $args->{possibleTargets} && ref $args->{possibleTargets} eq 'ARRAY'; + + my @filtered_targets; + foreach my $target_ID (@{ $args->{possibleTargets} }) { + my $target = $monsters{$target_ID}; + if ($target && should_drop_target_from_assisters($hook, $target, 'Not picking')) { + next; + } + + push @filtered_targets, $target_ID; + } + + @{ $args->{possibleTargets} } = @filtered_targets; +} - # Release close mobs that are no longer assisted, don't do this to distant mobs becase their assisters might just be out of range - if ($monsterDist < $max_dist_to_release) { - warning "[avoidAssisters] [$hook] Releasing nearby ($monsterDist < $max_dist_to_release) target $target from block, it no longer meets blocking criteria.\n"; +## Purpose: Handles the plugin's console command and prints the parsed rules. +## Args: `($cmd, $args)` from the command dispatcher, where `$args` is the raw +## argument string after `assist`. +## Returns: nothing. +## Notes: The command currently supports `dump` and `status`, both of which print +## the currently loaded plugin settings so runtime config state can be inspected +## without reopening `config.txt`. +sub command_avoidAssisters { + my ($cmd, $args) = @_; + $args = $cmd unless defined $args; + $args = '' unless defined $args; + $args =~ s/^\s+|\s+$//g; + + if ($args eq '' || $args eq 'conf' || $args eq 'dump' || $args eq 'status') { + print_avoidAssisters_configuration(); + return; + } + + message "[avoidAssisters] Usage: assist [conf|dump|status]\n"; +} + +## Purpose: Formats one scalar for the config dump table. +## Args: `($value)` where `$value` is the raw scalar to print. +## Returns: A printable string. +## Notes: This keeps empty values obvious in the dump instead of collapsing into +## Perl warnings or invisible blanks. +sub format_avoid_assisters_dump_value { + my ($value) = @_; + return '-' unless defined $value && $value ne ''; + return $value; +} + +## Purpose: Builds a short readable label for one assister actor in debug logs. +## Args: `($monster)` where `$monster` is a visible assister actor. +## Returns: A single-line description including binID, nameID, and position. +## Notes: The plugin's drop warnings use this helper so the trigger list is easy +## to scan while debugging repeated target blocks. +sub format_assister_actor_debug_label { + my ($monster) = @_; + return 'unknown assister' unless $monster; + + my $pos = $monster->{pos_to} || {}; + my $bin_id = defined $monster->{binID} ? $monster->{binID} : '?'; + my $name_id = defined $monster->{nameID} ? $monster->{nameID} : '?'; + my $x = defined $pos->{x} ? $pos->{x} : '?'; + my $y = defined $pos->{y} ? $pos->{y} : '?'; + + return "binID=$bin_id nameID=$name_id pos=($x,$y)"; +} + +## Purpose: Builds the detailed warning text for one drop decision. +## Args: `($target, $drop_info)` where `$drop_info` is the structured hashref +## returned by `find_assister_drop_reason`. +## Returns: A human-readable debug string. +## Notes: This consolidates the log format so both `shouldDropTarget` and +## `getBestTarget` warnings stay explicit and consistent. +sub format_assister_drop_reason_message { + my ($target, $drop_info) = @_; + return 'for an unknown avoidAssisters reason.' unless $target && $drop_info; + + my $target_pos = $drop_info->{targetPosition}{pos} || {}; + my $position_source = $drop_info->{targetPosition}{source} || 'unknown_pos'; + my $position_x = defined $target_pos->{x} ? $target_pos->{x} : '?'; + my $position_y = defined $target_pos->{y} ? $target_pos->{y} : '?'; + my $rule = $drop_info->{rule} || {}; + my $rule_scope = $rule->{ruleScope} || 'unknownRule'; + my $rule_block_key = format_avoid_assisters_dump_value($rule->{blockKey}); + my $rule_block_value = format_avoid_assisters_dump_value($rule->{blockValue}); + my $assister_descriptions = join('; ', map { format_assister_actor_debug_label($_) } @{ $drop_info->{assisters} || [] }); + $assister_descriptions = 'none captured' if $assister_descriptions eq ''; + + return sprintf( + "because %s matched at %s targetPos=(%s,%s); rule=%s blockValue=%s assisterMobID=%s checkRange=%s maxMobsInRange=%s counted=%s triggerAssisters=[%s].", + $rule_scope, + $position_source, + $position_x, + $position_y, + $rule_block_key, + $rule_block_value, + format_avoid_assisters_dump_value($drop_info->{assisterID}), + format_avoid_assisters_dump_value($rule->{checkRange}), + format_avoid_assisters_dump_value($rule->{maxMobsInRange}), + format_avoid_assisters_dump_value($drop_info->{count}), + $assister_descriptions, + ); +} + +## Purpose: Prints the currently parsed avoidAssisters configuration to the console. +## Args: none. +## Returns: nothing. +## Notes: This dumps the rule state exactly as loaded in memory, which is useful +## for confirming runtime config reloads and parsed numeric values. +sub print_avoidAssisters_configuration { + my $msg = center(" avoidAssisters Config ", 79, '-') . "\n"; + $msg .= sprintf( + "Per-target rules: %d Global rules: %d Visible cached nameIDs: %d\n", + scalar(@avoidAssisters_mobs), + scalar(@avoidGlobalAssisters_mobs), + scalar(keys %visible_monsters_by_nameID), + ); + $msg .= ('-' x 79) . "\n"; + $msg .= center(" Per-target Rules ", 79, '-') . "\n"; + $msg .= swrite( + "@<< @<<<<<<<<<<<<<<<<<< @<<<<<<<<<<<<<<<< @>>>>>> @>>>>>> @>>>>>>>>\n", + ['#', 'Block', 'Rule', 'MobID', 'Range', 'MaxMobs'] + ); + + if (@avoidAssisters_mobs) { + my $index = 0; + foreach my $rule (@avoidAssisters_mobs) { + $msg .= swrite( + "@<< @<<<<<<<<<<<<<<<<<< @<<<<<<<<<<<<<<<< @>>>>>> @>>>>>> @>>>>>>>>\n", + [ + $index, + format_avoid_assisters_dump_value($rule->{blockKey}), + format_avoid_assisters_dump_value($rule->{ruleKey}), + format_avoid_assisters_dump_value($rule->{id}), + format_avoid_assisters_dump_value($rule->{checkRange}), + format_avoid_assisters_dump_value($rule->{maxMobsInRange}), + ] + ); + $msg .= sprintf( + " blockValue=%s ruleValue=%s\n", + format_avoid_assisters_dump_value($rule->{blockValue}), + format_avoid_assisters_dump_value($rule->{ruleValue}), + ); + $index++; + } + } else { + $msg .= "No per-target avoidAssisters blocks are configured.\n"; + } + + $msg .= ('-' x 79) . "\n"; + $msg .= center(" Global Rules ", 79, '-') . "\n"; + $msg .= swrite( + "@<< @<<<<<<<<<<<<<<<<<< @<<<<<<<<<<<<<<<< @>>>>>> @>>>>>> @>>>>>>>>\n", + ['#', 'Block', 'Rule', 'MobID', 'Range', 'MaxMobs'] + ); + + if (@avoidGlobalAssisters_mobs) { + my $index = 0; + foreach my $rule (@avoidGlobalAssisters_mobs) { + $msg .= swrite( + "@<< @<<<<<<<<<<<<<<<<<< @<<<<<<<<<<<<<<<< @>>>>>> @>>>>>> @>>>>>>>>\n", + [ + $index, + format_avoid_assisters_dump_value($rule->{blockKey}), + format_avoid_assisters_dump_value($rule->{ruleKey}), + format_avoid_assisters_dump_value($rule->{id}), + format_avoid_assisters_dump_value($rule->{checkRange}), + format_avoid_assisters_dump_value($rule->{maxMobsInRange}), + ] + ); + $msg .= sprintf( + " blockValue=%s ruleValue=%s\n", + format_avoid_assisters_dump_value($rule->{blockValue}), + format_avoid_assisters_dump_value($rule->{ruleValue}), + ); + $index++; + } + } else { + $msg .= "No global avoidGlobalAssisters blocks are configured.\n"; + } + + $msg .= ('-' x 79) . "\n"; + message $msg, "list"; +} + +## Purpose: Applies the plugin's drop-or-release policy to one target. +## Args: `($hook, $target, $drop_string)` where `$hook` is the calling hook name, +## `$target` is the live monster actor, and `$drop_string` is a log label such as +## `Dropping` or `Not picking`. +## Returns: `1` when the target should stay blocked, otherwise `0`. +## Notes: This shared helper exists so `shouldDropTarget` and `getBestTarget` +## enforce the same blocker logic and state transitions. Already-engaged targets +## use a short confirmation timer before the first drop so brief unsafe states +## do not immediately cancel a fight. Released targets also respect a short +## cooldown before they can be unblocked again after an assister drop. +sub should_drop_target_from_assisters { + my ($hook, $target, $drop_string) = @_; + + my @target_positions = get_target_positions($target); + my $is_dropped = isTargetDroppedAssisters($target); + my @drop_infos = find_assister_drop_reasons_by_position($target, \@target_positions); + my $engaged_by_us = target_has_been_engaged_by_us($target); + my $must_drop = should_force_drop_target_from_assisters($hook, \@target_positions, \@drop_infos, $engaged_by_us); + + if ($must_drop) { + if ($hook eq 'shouldDropTarget' && $engaged_by_us && !$is_dropped) { + return 0 unless engaged_drop_confirmation_elapsed($target); + } + + clear_engaged_drop_confirmation($target); + start_assister_drop_release_cooldown($target) unless $is_dropped; + warning "[avoidAssisters] [$hook] $drop_string target $target (binID: " . format_avoid_assisters_dump_value($target->{binID}) . ", nameID: " . format_avoid_assisters_dump_value($target->{nameID}) . ") " . join(' ALSO ', map { format_assister_drop_reason_message($target, $_) } @drop_infos) . "\n" if !$is_dropped; + $target->{attackFailedAssisters} = 1; + return 1; + } + + clear_engaged_drop_confirmation($target); + + if ($is_dropped) { + my ($can_release, $monster_dist, $max_dist_to_release, $required_check_range) = can_release_target_from_assisters($target, \@target_positions); + if ($can_release) { + warning "[avoidAssisters] [$hook] Releasing nearby ($monster_dist <= $max_dist_to_release) target $target from block, it no longer meets blocking criteria and we are close enough to fully see its assister range ($required_check_range).\n"; $target->{attackFailedAssisters} = 0; - } else { - $args->{return} = 1; + clear_engaged_drop_confirmation($target); + clear_assister_drop_release_cooldown($target); + return 0; + } + + return 1; + } + + return 0; +} + +## Purpose: Reports whether we have already started attacking this target. +## Args: `($target)` where `$target` is the live monster actor being checked. +## Returns: `1` when we have already engaged the target, otherwise `0`. +## Notes: This lets `shouldDropTarget` use stricter drop rules only after combat +## has actually started, while `getBestTarget` keeps the earlier cheap screening. +## `sentAttack` and `engaged` are checked first because they reflect local +## attack commitment earlier than delayed server damage feedback. +sub target_has_been_engaged_by_us { + my ($target) = @_; + return 0 unless $target; + return 1 if ($target->{sentAttack} || 0) == 1; + return 1 if ($target->{engaged} || 0) == 1; + return 1 if (($target->{numAtkFromYou} || 0) > 0); + return 1 if (($target->{dmgFromYou} || 0) > 0); + return 1 if (($target->{missedFromYou} || 0) > 0); + return 0; +} + +## Purpose: Starts or checks the engaged-target drop confirmation timer. +## Args: `($target)` where `$target` is the currently evaluated monster actor. +## Returns: `1` when the target has stayed unsafe for the configured delay, +## otherwise `0`. +## Notes: This exists so already-engaged targets are only dropped after the +## unsafe state persists continuously, which better tolerates server lag and +## brief position mismatch. +sub engaged_drop_confirmation_elapsed { + my ($target) = @_; + return 1 unless $target; + return 1 if ENGAGED_DROP_CONFIRMATION_DELAY <= 0; + + my $now = time; + if (!defined $target->{avoidAssistersEngagedDropSince}) { + $target->{avoidAssistersEngagedDropSince} = $now; + return 0; + } + + return (($now - $target->{avoidAssistersEngagedDropSince}) >= ENGAGED_DROP_CONFIRMATION_DELAY) ? 1 : 0; +} + +## Purpose: Clears any pending engaged-target drop confirmation timer. +## Args: `($target)` where `$target` is the monster actor being reset. +## Returns: nothing. +## Notes: The timer is cleared whenever the target becomes safe again or after a +## release so later danger checks must prove a fresh continuous unsafe interval. +sub clear_engaged_drop_confirmation { + my ($target) = @_; + return unless $target; + delete $target->{avoidAssistersEngagedDropSince}; +} + +## Purpose: Starts the cooldown that delays releasing a just-dropped target. +## Args: `($target)` where `$target` is the monster actor that has just been blocked. +## Returns: nothing. +## Notes: This exists to prevent immediate repicks when visibility briefly flickers +## after an assister-based drop. +sub start_assister_drop_release_cooldown { + my ($target) = @_; + return unless $target; + $target->{avoidAssistersDroppedAt} = time; +} + +## Purpose: Clears the post-drop release cooldown marker for one target. +## Args: `($target)` where `$target` is the monster actor being reset. +## Returns: nothing. +## Notes: The cooldown only matters while the target is blocked, so it is removed +## when the target is released again. +sub clear_assister_drop_release_cooldown { + my ($target) = @_; + return unless $target; + delete $target->{avoidAssistersDroppedAt}; +} + +## Purpose: Reports whether the post-drop release cooldown has elapsed. +## Args: `($target)` where `$target` is the blocked monster actor being checked. +## Returns: `1` when release is allowed to proceed, otherwise `0`. +## Notes: This adds a short hysteresis window after assister drops so we do not +## instantly re-pick the same target on one safe-looking tick. +sub assister_drop_release_cooldown_elapsed { + my ($target) = @_; + return 1 unless $target; + return 1 if ASSISTER_DROP_RELEASE_COOLDOWN <= 0; + return 1 unless defined $target->{avoidAssistersDroppedAt}; + return ((time - $target->{avoidAssistersDroppedAt}) >= ASSISTER_DROP_RELEASE_COOLDOWN) ? 1 : 0; +} + +## Purpose: Decides whether the current collected drop reasons require a drop. +## Args: `($hook, $target_positions, $drop_infos, $engaged_by_us)`. +## Returns: `1` when the target must be dropped, otherwise `0`. +## Notes: `getBestTarget` and unengaged `shouldDropTarget` drop on the first bad +## position, but engaged `shouldDropTarget` requires every checked target +## position to fail before abandoning the target. +sub should_force_drop_target_from_assisters { + my ($hook, $target_positions, $drop_infos, $engaged_by_us) = @_; + return 0 unless $drop_infos && @{$drop_infos}; + + if ($hook eq 'shouldDropTarget' && $engaged_by_us) { + return 0 unless $target_positions && @{$target_positions}; + return scalar(@{$drop_infos}) == scalar(@{$target_positions}); + } + + return 1; +} + +## Purpose: Collects the target positions that should be checked for safety. +## Args: `($target)` where `$target` is the monster actor being evaluated. +## Returns: A list of hashrefs containing `source` and `pos`. +## Notes: The plugin checks both the current predicted pathfinding position and +## `pos_to` so moving targets cannot bypass the assister checks due to stale data. +sub get_target_positions { + my ($target) = @_; + return unless $target; + + my @target_positions; + my $target_calc_pos = calcPosFromPathfinding($field, $target); + push @target_positions, { source => 'calc_pos', pos => $target_calc_pos } if $target_calc_pos; + + my $same_as_calc = $target_calc_pos + && $target->{pos_to} + && $target_calc_pos->{x} == $target->{pos_to}{x} + && $target_calc_pos->{y} == $target->{pos_to}{y}; + push @target_positions, { source => 'pos_to', pos => $target->{pos_to} } if $target->{pos_to} && !$same_as_calc; + + return @target_positions; +} + +## Purpose: Finds the first assister rule violation for a target, if any. +## Args: `($target, $target_positions)` where `$target_positions` is an arrayref +## of position-descriptor hashrefs returned by `get_target_positions`. +## Returns: A structured hashref describing the first blocking rule, or +## `undef` when no rule currently blocks it. +## Notes: This helper centralizes both per-target and global assister checks so +## blocking and release logic can rely on one source of truth. +sub find_assister_drop_reasons_by_position { + my ($target, $target_positions) = @_; + my @drop_infos; + + foreach my $target_position (@{ $target_positions || [] }) { + my $drop_info = find_assister_drop_reason_for_position($target, $target_position); + push @drop_infos, $drop_info if $drop_info; + } + + return @drop_infos; +} + +## Purpose: Finds the first assister rule violation for one target position. +## Args: `($target, $target_position)` where `$target_position` is one hashref +## returned by `get_target_positions`. +## Returns: A structured hashref describing the first blocking rule, or `undef`. +## Notes: This is the per-position evaluator used to distinguish `calc_pos` and +## `pos_to` outcomes when deciding whether an engaged target must be dropped. +sub find_assister_drop_reason_for_position { + my ($target, $target_position) = @_; + return unless $target && $target_position && $target_position->{pos}; + + my $mob_id = $target->{nameID}; + my $target_rules = $avoidAssisters_rules_by_target_nameID{$mob_id}; + + if ($target_rules) { + foreach my $avoidAssister_mob (@{$target_rules}) { + my $result = count_assisters_in_range($target, $target_position->{pos}, $mob_id, $avoidAssister_mob->{checkRange}, $avoidAssister_mob->{maxMobsInRange}); + if ($result->{count} > $avoidAssister_mob->{maxMobsInRange}) { + return { + rule => $avoidAssister_mob, + targetPosition => $target_position, + assisterID => $mob_id, + count => $result->{count}, + assisters => $result->{assisters}, + }; + } + } + } + + foreach my $avoid_global_mob (@avoidGlobalAssisters_mobs) { + my $result = count_assisters_in_range($target, $target_position->{pos}, $avoid_global_mob->{id}, $avoid_global_mob->{checkRange}, $avoid_global_mob->{maxMobsInRange}); + if ($result->{count} > $avoid_global_mob->{maxMobsInRange}) { + return { + rule => $avoid_global_mob, + targetPosition => $target_position, + assisterID => $avoid_global_mob->{id}, + count => $result->{count}, + assisters => $result->{assisters}, + }; } } + + return; +} + +## Purpose: Counts assister mobs of one ID near a target position. +## Args: `($target, $target_pos, $assister_id, $check_range)` where `$target` is +## the target monster, `$target_pos` is the position being checked, `$assister_id` +## is the assister monster ID to count, `$check_range` is the allowed radius, and +## `$max_allowed` is the rule threshold used for early-exit optimization. +## Returns: A hashref with `count` and `assisters`. +## Notes: The target itself is excluded, and mobs already fighting a player are +## ignored so only free potential assisters are counted. The nameID cache is used +## as a cheap early-exit, and the per-nameID bucket keeps the positional scan +## limited to monsters of the relevant assister type. +sub count_assisters_in_range { + my ($target, $target_pos, $assister_id, $check_range, $max_allowed) = @_; + return { count => 0, assisters => [] } unless $target && $target_pos; + prune_hidden_cached_assisters_that_should_be_visible($assister_id); + + my $visible_bucket = $visible_monsters_by_nameID{$assister_id}; + return { count => 0, assisters => [] } unless $visible_bucket; + + my $visible_count = $visible_monster_count_by_nameID{$assister_id} || 0; + $visible_count-- if defined $target->{nameID} && $target->{nameID} == $assister_id && defined $target->{ID} && exists $visible_bucket->{$target->{ID}}; + return { count => 0, assisters => [] } if $visible_count <= 0; + return { count => 0, assisters => [] } if defined $max_allowed && $visible_count <= $max_allowed; + + my $count = 0; + my @assisters; + foreach my $monster (values %{$visible_bucket}) { + next if $monster->{ID} eq $target->{ID}; + next unless $monster->{nameID} == $assister_id; + next if isMobFightingSomeoneElse($monster); + next unless $monster->{pos_to}; + next if blockDistance($monster->{pos_to}, $target_pos) > $check_range; + $count++; + push @assisters, $monster; + last if defined $max_allowed && $count > $max_allowed; + } + + return { + count => $count, + assisters => \@assisters, + }; +} + +## Purpose: Removes hidden cached assisters once they should be visible again. +## Args: `($assister_id)` where `$assister_id` is the nameID bucket being checked. +## Returns: nothing. +## Notes: Relevant assisters that moved off screen are cached conservatively, but +## once we stand close enough to their last known position that they should be in +## view again, the stale cache entry is discarded. +sub prune_hidden_cached_assisters_that_should_be_visible { + my ($assister_id) = @_; + return unless defined $assister_id; + return unless $field && $char; + + my $bucket = $visible_monsters_by_nameID{$assister_id}; + return unless $bucket; + + my $myPos = calcPosFromPathfinding($field, $char); + return unless $myPos; + + my $visibility_dist = $config{clientSight} - RELEASE_VISIBILITY_MARGIN; + $visibility_dist = 0 if $visibility_dist < 0; + + my @stale_monsters; + foreach my $monster (values %{$bucket}) { + next unless $monster; + next unless ($monster->{disappeared} || 0) == 1; + next if ($monster->{dead} || 0) == 1; + next if ($monster->{teleported} || 0) == 1; + next unless $monster->{pos_to}; + next if blockDistance($myPos, $monster->{pos_to}) > $visibility_dist; + push @stale_monsters, $monster; + } + + decrement_visible_monster_count($_) for @stale_monsters; +} + +## Purpose: Checks whether a previously blocked target can be safely released. +## Args: `($target, $target_positions)` where `$target_positions` is an arrayref +## of positions gathered by `get_target_positions`. +## Returns: `($can_release, $monster_dist, $max_dist_to_release, $required_check_range)`. +## Notes: Release is intentionally stricter than drop; it only happens when the +## target no longer violates any rule and we are close enough for `clientSight` +## to cover the full assister radius around every checked target position. A +## small extra safety margin is applied so targets are only released when we are +## comfortably inside the sight requirement, not exactly on its edge. A short +## cooldown after drop must also elapse before the target can be released again. +sub can_release_target_from_assisters { + my ($target, $target_positions) = @_; + return (0, undef, undef, undef) unless $target && $field && $char; + return (0, undef, undef, undef) unless assister_drop_release_cooldown_elapsed($target); + + my $required_check_range = get_required_release_check_range($target); + return (1, 0, 0, 0) unless defined $required_check_range; + + my $myPos = calcPosFromPathfinding($field, $char); + return (0, undef, undef, $required_check_range) unless $myPos; + + my $monsterDist; + foreach my $target_position (@{$target_positions}) { + next unless $target_position && $target_position->{pos}; + my $dist = blockDistance($myPos, $target_position->{pos}); + $monsterDist = $dist if !defined $monsterDist || $dist > $monsterDist; + } + + return (0, undef, undef, $required_check_range) unless defined $monsterDist; + + my $max_dist_to_release = ($config{clientSight} - $required_check_range - RELEASE_VISIBILITY_MARGIN); + $max_dist_to_release = 0 if $max_dist_to_release < 0; + return ($monsterDist <= $max_dist_to_release, $monsterDist, $max_dist_to_release, $required_check_range); +} + +## Purpose: Computes the assister radius that must be fully visible before release. +## Args: `($target)` where `$target` is the monster currently flagged as blocked. +## Returns: The largest relevant `checkRange` for that target, or `undef` if no +## current rules apply anymore. +## Notes: This helper exists so release logic stays conservative when multiple +## rules can affect the same target. +sub get_required_release_check_range { + my ($target) = @_; + return unless $target; + + my $mob_id = $target->{nameID}; + my $required_check_range; + my $target_rules = $avoidAssisters_rules_by_target_nameID{$mob_id}; + + if ($target_rules) { + foreach my $avoidAssister_mob (@{$target_rules}) { + $required_check_range = $avoidAssister_mob->{checkRange} + if !defined $required_check_range || $avoidAssister_mob->{checkRange} > $required_check_range; + } + } + + foreach my $avoid_global_mob (@avoidGlobalAssisters_mobs) { + $required_check_range = $avoid_global_mob->{checkRange} + if !defined $required_check_range || $avoid_global_mob->{checkRange} > $required_check_range; + } + + return $required_check_range; } +## Purpose: Reports whether a target is currently marked as blocked by this plugin. +## Args: `($target)` where `$target` is the monster actor being checked. +## Returns: `1` if the plugin previously marked the target as blocked, otherwise `0`. +## Notes: This is used to suppress duplicate warnings and to control later release behavior. sub isTargetDroppedAssisters { my ($target) = @_; return 1 if (exists $target->{attackFailedAssisters} && $target->{attackFailedAssisters} == 1); return 0; } -sub isMobFightingPlayer { +## Purpose: Checks whether a monster is already engaged with any player. +## Args: `($mob)` where `$mob` is the monster actor being examined. +## Returns: `1` if player combat interaction is recorded for that mob, otherwise `0`. +## Notes: The plugin ignores engaged mobs so it only counts free nearby monsters +## as potential assisters. +sub isMobFightingSomeoneElse { my ($mob) = @_; if (scalar(keys %{$mob->{missedFromPlayer}}) == 0 && scalar(keys %{$mob->{dmgFromPlayer}}) == 0 @@ -240,7 +1314,6 @@ sub isMobFightingPlayer { && scalar(keys %{$mob->{missedToPlayer}}) == 0 && scalar(keys %{$mob->{dmgToPlayer}}) == 0 && scalar(keys %{$mob->{castOnToPlayer}}) == 0 - #&& !objectIsMovingTowardsPlayer($monster) ) { return 0; } else { diff --git a/plugins/avoidObstacles/avoidObstacles.pl b/plugins/avoidObstacles/avoidObstacles.pl index b163c86d59..a9efcccf2b 100644 --- a/plugins/avoidObstacles/avoidObstacles.pl +++ b/plugins/avoidObstacles/avoidObstacles.pl @@ -130,7 +130,7 @@ package avoidObstacles; my %cached_final_grid_index; my $chooks = Commands::register( - ['avoid', 'avoidObstacles controls: od [dump|reload|status]', \&command_avoid], + ['avoid', 'avoidObstacles controls: avoid [dump|reload|status]', \&command_avoid], ); my %plugin_settings; @@ -763,7 +763,7 @@ sub command_avoid { return; } - message "[" . PLUGIN_NAME . "] Usage: od [dump|reload|status]\n", 'list'; + message "[" . PLUGIN_NAME . "] Usage: avoid [dump|reload|status]\n", 'list'; } ## Purpose: Dumps the main live obstacle caches for debugging. diff --git a/src/AI.pm b/src/AI.pm index 6b8d3e0ee9..61c3d60c86 100644 --- a/src/AI.pm +++ b/src/AI.pm @@ -31,6 +31,7 @@ use Field; use Exporter; use base qw(Exporter); use Translation; +use Misc; our @EXPORT = ( qw/ @@ -54,6 +55,7 @@ our @EXPORT = ( ai_storageAutoCheck ai_canOpenStorage ai_canStartStorageSellBuy + StorageSellBuy_aiClear shouldStartAutoStorage shouldStartAutoSell shouldStartAutoBuy @@ -599,6 +601,9 @@ sub ai_canStartStorageSellBuy { Plugins::callHook('ai_canStartStorageSellBuy' => \%plugin_args); return 0 if ($plugin_args{return}); + #return 0 if (%talk); + #return 0 if ((defined $ai_v{'npc_talk'} && ref($ai_v{'npc_talk'}) eq 'HASH' && scalar keys %{$ai_v{'npc_talk'}} > 0)); + return 1 if (AI::isIdle()); return 0 if (AI::inQueue("storageAuto", "buyAuto", "sellAuto", "teleport", "NPC", "skill_use", "eventMacro")); @@ -617,6 +622,10 @@ sub ai_canStartStorageSellBuy { return 0; } +sub StorageSellBuy_aiClear { + AI::clear("move", "route", "attack", "items_take", "take", "items_gather"); +} + sub shouldStartAutoStorage { return unless (ai_canOpenStorage()); return unless ($config{storageAuto}); @@ -628,7 +637,7 @@ sub shouldStartAutoStorage { Plugins::callHook('AI_storage_auto_onStart' => \%plugin_args); unless ($plugin_args{return}) { message T("Auto-storaging due to storageAuto_onStart\n"); - AI::clear("sitAuto", "follow", "mapRoute", "route", "move", "attack"); + StorageSellBuy_aiClear(); AI::queue("storageAuto"); Plugins::callHook('AI_storage_auto_queued'); $timeout{'ai_storageAuto'}{'time'} = time; @@ -649,7 +658,7 @@ sub shouldStartAutoStorage { Plugins::callHook('AI_storage_auto_limit_reached' => \%plugin_args); unless ($plugin_args{return}) { message TF("Auto-storaging due to %s\n", join('|', @reasons)); - AI::clear("sitAuto", "follow", "mapRoute", "route", "move", "attack"); + StorageSellBuy_aiClear(); AI::queue("storageAuto"); Plugins::callHook('AI_storage_auto_queued'); $timeout{'ai_storageAuto'}{'time'} = time; @@ -722,7 +731,7 @@ sub shouldStartAutoStorage { Plugins::callHook('AI_storage_auto_getAuto_needitem' => \%plugin_args); unless ($plugin_args{return}) { message TF("Auto-storaging due to insufficient %s\n", $needitem); - AI::clear("sitAuto", "follow", "mapRoute", "route", "move", "attack"); + StorageSellBuy_aiClear(); AI::queue("storageAuto"); Plugins::callHook('AI_storage_auto_queued'); $timeout{'ai_storageAuto'}{'time'} = time; @@ -749,7 +758,7 @@ sub shouldStartAutoSell { Plugins::callHook('AI_sell_auto_start' => \%plugin_args); unless ($plugin_args{return}) { message TF("Auto-selling due to %s\n", join('|', @reasons)); - AI::clear("sitAuto", "follow", "mapRoute", "route", "move", "attack"); + StorageSellBuy_aiClear(); AI::queue("sellAuto"); Plugins::callHook('AI_sell_auto_queued'); $timeout{'ai_sellAuto'}{'time'} = time; @@ -762,7 +771,7 @@ sub shouldStartAutoSell { sub shouldStartAutoBuy { my %plugin_args = ( return => 0 ); Plugins::callHook('AI_buy_auto_start' => \%plugin_args); - return if ($plugin_args{return}); + return $plugin_args{return_result} if ($plugin_args{return}); return unless (timeOut($timeout{'ai_buyAuto'})); $timeout{'ai_buyAuto'}{'time'} = time; @@ -801,7 +810,7 @@ sub shouldStartAutoBuy { Plugins::callHook('AI_buy_auto_needitem' => \%plugin_args); unless ($plugin_args{return}) { message TF("Auto-buying due to insufficient %s\n", $needitem); - AI::clear("sitAuto", "follow", "mapRoute", "route", "move", "attack"); + StorageSellBuy_aiClear(); AI::queue("buyAuto"); Plugins::callHook('AI_buy_auto_queued'); return 1 diff --git a/src/AI/Attack.pm b/src/AI/Attack.pm index 1e3403d183..5c92bb8c65 100644 --- a/src/AI/Attack.pm +++ b/src/AI/Attack.pm @@ -90,6 +90,8 @@ sub process { $plugin_args{stage} = $stage; $plugin_args{party} = $assistParty; $plugin_args{target_is_aggressive} = $target_is_aggressive; + $plugin_args{actor} = $char; + $plugin_args{configPrefix} = ''; $plugin_args{return} = 0; Plugins::callHook('shouldDropTarget' => \%plugin_args); if ($plugin_args{return}) { @@ -108,7 +110,7 @@ sub process { my @aggressives = $effectiveAttackMode >= 0 ? ai_getAggressives($aggressiveType, $assistParty) : (); if (!$target_is_aggressive && @aggressives) { - my $attackTarget = getBestTarget(\@aggressives, $config{attackCheckLOS}, $config{attackCanSnipe}); + my $attackTarget = getBestTarget(\@aggressives, $config{attackCheckLOS}, $config{attackCanSnipe}, $char, ''); if ($attackTarget && $attackTarget ne $target->{ID}) { $char->sendAttackStop; AI::dequeue() while ( AI::inQueue("attack") ); @@ -160,32 +162,9 @@ sub process { } # We're on route to the monster; check whether the monster has moved - if ($args->{attackID} && timeOut($timeout{ai_attack_route_adjust})) { - if ( - $target->{type} ne 'Unknown' && - $ataqArgs->{monsterLastMoveTime} && - $ataqArgs->{monsterLastMoveTime} != $target->{time_move} - ) { - if ( - ($args->{monsterLastMovePosTo}{x} == $target->{pos_to}{x} && $args->{monsterLastMovePosTo}{y} == $target->{pos_to}{y}) - ) { - $args->{monsterLastMoveTime} = $target->{time_move}; - $args->{monsterLastMovePosTo}{x} = $target->{pos_to}{x}; - $args->{monsterLastMovePosTo}{y} = $target->{pos_to}{y}; - } else { - # Monster has moved; stop moving and let the attack AI readjust route - debug "Target $target has moved since we started routing to it - Adjusting route\n", "ai_attack"; - AI::dequeue() while (AI::is("move", "route")); - - $ataqArgs->{ai_attack_giveup}{time} = time; - $ataqArgs->{sentApproach} = 0; - undef $args->{unstuck}{time}; - undef $args->{avoiding}; - undef $args->{move_start}; - } - } else { - $timeout{ai_attack_route_adjust}{time} = time; - } + if ($args->{attackID} && approach_target_route_needs_reset($ataqArgs, $target)) { + reset_approach_for_moved_target($ataqArgs, $target); + return; } } @@ -237,6 +216,40 @@ sub shouldGiveUp { return !$config{attackNoGiveup} && (timeOut($args->{ai_attack_giveup}) || $args->{unstuck}{count} > 5); } +sub approach_target_route_needs_reset { + my ($args, $target) = @_; + return 0 unless $args && $target; + return 0 if $target->{type} eq 'Unknown'; + return 0 unless $args->{sentApproach}; + return 0 unless $args->{monsterLastMoveTime}; + return 0 unless $args->{monsterLastMoveTime} != $target->{time_move}; + return 0 unless $target->{pos_to}; + + if ($args->{monsterLastMovePosTo}) { + return 0 + if $args->{monsterLastMovePosTo}{x} == $target->{pos_to}{x} + && $args->{monsterLastMovePosTo}{y} == $target->{pos_to}{y}; + } + + return 1; +} + +sub reset_approach_for_moved_target { + my ($args, $target) = @_; + return unless $args && $target; + + debug "Target $target has moved since we started routing to it - Adjusting route\n", "ai_attack"; + AI::dequeue() while (AI::is("move", "route")); + + $args->{ai_attack_giveup}{time} = time; + $args->{sentApproach} = 0; + $args->{monsterLastMoveTime} = $target->{time_move}; + $args->{monsterLastMovePosTo} = { %{$target->{pos_to}} } if $target->{pos_to}; + undef $args->{unstuck}{time}; + undef $args->{avoiding}; + undef $args->{move_start}; +} + sub giveUp { my ($args, $ID, $reason) = @_; my $target = Actor::get($ID); @@ -253,6 +266,9 @@ sub giveUp { if ($config{'teleportAuto_dropTarget'}) { message T("Teleport due to dropping attack target\n"); ai_useTeleport(1); + } elsif ($config{'teleportAuto_dropTargetEngaged'} && ($target->{sentAttack} || $target->{engaged})) { + message T("Teleport due to dropping attack target already engaged\n"); + ai_useTeleport(1); } } @@ -435,9 +451,9 @@ sub main { my $extra_time = exists $timeout{'ai_route_position_prediction_delay'}{'timeout'} ? $timeout{'ai_route_position_prediction_delay'}{'timeout'} : 0.1; $extra_time = 0 unless (defined $extra_time); - my $myPos = $char->{pos_to}; + my $myPosTo = $char->{pos_to}; my $monsterPos = $target->{pos_to}; - my $monsterDist = blockDistance($myPos, $monsterPos); + my $monsterDist = blockDistance($myPosTo, $monsterPos); my $realMyPos = calcPosFromPathfinding($field, $char, $extra_time); my $realMonsterPos = calcPosFromPathfinding($field, $target, $extra_time); @@ -445,7 +461,20 @@ sub main { my $realMonsterDist = blockDistance($realMyPos, $realMonsterPos); my $clientDist = getClientDist($realMyPos, $realMonsterPos); - + if (!exists $args->{firstLoop}) { + $args->{firstLoop} = 1; + } else { + $args->{firstLoop} = 0; + } + + my $hitYou = ((defined $args->{dmgToYou_last} && $args->{dmgToYou_last} != $target->{dmgToYou}) || (defined $args->{missedYou_last} && $args->{missedYou_last} != $target->{missedYou})) ? 1 : 0; + my $casOnYou = (defined $args->{castOnToYou_last} && $args->{castOnToYou_last} != $target->{castOnToYou}) ? 1 : 0; + my $youHitTarget = ((defined $args->{dmgFromYou_last} && $args->{dmgFromYou_last} != $target->{dmgFromYou}) || (defined $args->{missedFromYou_last} && $args->{missedFromYou_last} != $target->{missedFromYou})) ? 1 : 0; + + if ($hitYou || $casOnYou || $args->{dmgFromYou_last} != $target->{dmgFromYou} || ($args->{firstLoop} && ($target->{dmgToYou} || $target->{missedYou} || $target->{dmgFromYou} || $target->{castOnToYou}))) { + $target->{engaged} = 1 if (!exists $target->{engaged} || !$target->{engaged}); + } + # If the damage numbers have changed, update the giveup time so we don't timeout if ($args->{dmgToYou_last} != $target->{dmgToYou} || $args->{missedYou_last} != $target->{missedYou} @@ -454,18 +483,10 @@ sub main { $args->{ai_attack_giveup}{time} = time; debug "Update attack giveup time\n", "ai_attack", 2; } - - if (!exists $args->{firstLoop}) { - $args->{firstLoop} = 1; - } else { - $args->{firstLoop} = 0; - } - - my $hitYou = ($args->{dmgToYou_last} != $target->{dmgToYou} || $args->{missedYou_last} != $target->{missedYou}); - my $youHitTarget = ($args->{dmgFromYou_last} != $target->{dmgFromYou} || $args->{missedFromYou_last} != $target->{missedFromYou}); $args->{dmgToYou_last} = $target->{dmgToYou}; $args->{missedYou_last} = $target->{missedYou}; + $args->{castOnToYou_last} = $target->{castOnToYou}; $args->{dmgFromYou_last} = $target->{dmgFromYou}; $args->{missedFromYou_last} = $target->{missedFromYou}; @@ -596,22 +617,22 @@ sub main { # Here we check if we have finished moving to the meeting position to attack our target, only checks this if attackWaitApproachFinish is set to 1 in config # If so sets sentApproach to 0 if ($args->{sentApproach}) { - if ($config{"attackWaitApproachFinish"}) { - if (!timeOut($char->{time_move}, $char->{time_move_calc})) { - debug TF("[attackWaitApproachFinish - Waiting] %s (%d %d), target %s (%d %d), distance %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; - return; - } else { - debug TF("[attackWaitApproachFinish - Ended Approaching] %s (%d %d), target %s (%d %d), distance %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; - $args->{sentApproach} = 0; - } - } else { - if ($canAttack == 2) { - debug TF("[Approaching - Can now attack] %s (%d %d), target %s (%d %d), distance %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; - $args->{sentApproach} = 0; - } elsif (timeOut($char->{time_move}, $char->{time_move_calc})) { - debug TF("[Approaching - Ended] Still no LOS/Range - %s (%d %d), target %s (%d %d), distance %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; - $args->{sentApproach} = 0; - } + if (approach_target_route_needs_reset($args, $target)) { + reset_approach_for_moved_target($args, $target); + return; + } + + if ($realMyPos->{x} == $myPosTo->{x} && $realMyPos->{y} == $myPosTo->{y}) { + debug TF("[Ended Approaching] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; + $args->{sentApproach} = 0; + + } elsif ($config{"attackWaitApproachFinish"}) { + debug TF("[attackWaitApproachFinish - Waiting] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; + return; + + } elsif ($canAttack == 2) { + debug TF("[Approaching - Can now attack] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; + $args->{sentApproach} = 0; } } @@ -743,6 +764,7 @@ sub main { $args->{move_start} = time; $args->{monsterLastMoveTime} = $target->{time_move}; + $args->{monsterLastMovePosTo} = { %{$target->{pos_to}} } if $target->{pos_to}; $args->{sentApproach} = 1; my $sendAttackWithMove = 0; @@ -777,6 +799,7 @@ sub main { # Attack the target. In case of tanking, only attack if it hasn't been hit once. if (!$args->{firstAttack}) { $args->{firstAttack} = 1; + $target->{sentAttack} = 1; debug "Ready to attack target $target ($realMonsterPos->{x} $realMonsterPos->{y}) ($realMonsterDist blocks away); we're at ($realMyPos->{x} $realMyPos->{y})\n", "ai_attack"; } @@ -787,7 +810,7 @@ sub main { # Our recorded position might be out of sync, so try to unstuck $args->{unstuck}{time} = time; debug("Attack - trying to unstuck\n", "ai_attack"); - $char->move(@{$myPos}{qw(x y)}); + $char->move(@{$myPosTo}{qw(x y)}); $args->{unstuck}{count}++; } @@ -878,4 +901,4 @@ sub main { Plugins::callHook('AI::Attack::main', {target => $target}) } -1; \ No newline at end of file +1; diff --git a/src/AI/CoreLogic.pm b/src/AI/CoreLogic.pm index fb06acaa66..2b10432876 100644 --- a/src/AI/CoreLogic.pm +++ b/src/AI/CoreLogic.pm @@ -858,7 +858,7 @@ sub processTake { AI::dequeue(); } elsif (AI::action() eq "take") { - my $myPos = $char->{pos}; + my $myPos = calcPosFromPathfinding($field, $char); my $dist = blockDistance($item->{pos}, $myPos); debug "Planning to take $item->{name} ($item->{binID}), distance $dist\n", "drop"; @@ -868,6 +868,7 @@ sub processTake { } elsif ($dist <= 2 && $config{'itemsTakeGreed'} && $char->{skills}{BS_GREED}{lv} >= 1) { my $skill = new Skill(handle => 'BS_GREED'); ai_skillUse2($skill, $char->{skills}{BS_GREED}{lv}, 1, 0, $char, "BS_GREED"); + } elsif ($dist > 1 && timeOut(AI::args()->{time_route}, $timeout{ai_take_giveup}{timeout})) { my $pos = $item->{pos}; AI::args()->{time_route} = time; @@ -875,9 +876,9 @@ sub processTake { $field->baseName, $pos->{x}, $pos->{y}, - maxRouteDistance => $config{'attackRouteMaxPathDistance'}, noSitAuto => 1, distFromGoal => 1, + attackOnRoute => 0, isItemTake => 1 ); } elsif (timeOut($timeout{ai_take})) { @@ -1242,9 +1243,14 @@ sub processStartAutoStorageBuySell { return if ($shopstarted || $buyershopstarted); return if ($ai_v{sitAuto_forcedBySitCommand} || !$char->inventory->isReady()); return unless (ai_canStartStorageSellBuy()); + return if (shouldStartAutoStorage()); return if (shouldStartAutoSell()); return if (shouldStartAutoBuy()); + + my %plugin_args = ( return => 0 ); + Plugins::callHook('processStartAutoStorageBuySell_end' => \%plugin_args); + return if ($plugin_args{return}); } ##### AUTO STORAGE ##### @@ -2224,12 +2230,12 @@ sub processLockMap { sub processRescueSlave { if ( - (AI::isIdle() || (AI::is('route') && AI::args()->{isRandomWalk})) + (AI::isIdle() || (AI::is('route'))) && $char->{slaves} ) { my $slave = AI::SlaveManager::mustRescue(); if (defined $slave) { - AI::dequeue() while (AI::is(qw/move route mapRoute/) && AI::args()->{isRandomWalk}); + AI::dequeue() while (AI::is(qw/move route mapRoute/)); ai_route( $field->baseName, $slave->{pos_to}{x}, @@ -2247,7 +2253,7 @@ sub processRescueSlave { # route_randomWalk_stopDuringSlaveAttack sub processRandomWalk_stopDuringSlaveAttack { - if (AI::is('route') && AI::args()->{isRandomWalk} + if (AI::is('route') && $char->{slaves} && !AI::SlaveManager::isIdle() ){ @@ -2258,7 +2264,7 @@ sub processRandomWalk_stopDuringSlaveAttack { # we shoudl probably not stop it, just not send new move commands after the current one $char->sendAttackStop; # TODO: This should probably just pause route instead of dequeuing it - AI::dequeue() while (AI::is(qw/move route mapRoute/) && AI::args()->{isRandomWalk}); + AI::dequeue() while (AI::is(qw/move route mapRoute/)); } } } @@ -3016,10 +3022,10 @@ sub processAutoAttack { return if (AI::inQueue("attack")); return if (!$field); - if ((AI::isIdle() || AI::is(qw/route follow sitAuto take items_gather items_take/) || (AI::action() eq "mapRoute" && AI::args()->{stage} eq 'Getting Map Solution')) + if (AI::isIdle() || AI::is(qw/route follow sitAuto take items_gather items_take/) # Don't auto-attack monsters while taking loot, and itemsTake/GatherAuto >= 2 - && !($config{'itemsTakeAuto'} >= 2 && AI::is("take", "items_take")) - && !($config{'itemsGatherAuto'} >= 2 && AI::is("take", "items_gather")) + && !($config{'itemsTakeAuto'} >= 2 && AI::inQueue("take", "items_take")) + && !($config{'itemsGatherAuto'} >= 2 && AI::inQueue("take", "items_gather")) && timeOut($timeout{ai_attack_auto}) && $ai_v{temp}{searchMonsters} >= $config{teleportAuto_search} && (!$config{attackAuto_notInTown} || !$field->isCity) @@ -3180,12 +3186,12 @@ sub processAutoAttack { # We define whether we should attack only monsters in LOS or not my $checkLOS = $config{attackCheckLOS}; my $canSnipe = $config{attackCanSnipe}; - $attackTarget = getBestTarget(\@skillCancelMonsters, $checkLOS, $canSnipe) || - getBestTarget(\@looterMonsters, $checkLOS, $canSnipe) || - getBestTarget(\@aggressives, $checkLOS, $canSnipe) || - getBestTarget(\@partyMonsters, $checkLOS, $canSnipe) || - getBestTarget(\@droppedMonsters, $checkLOS, $canSnipe) || - getBestTarget(\@cleanMonsters, $checkLOS, $canSnipe); + $attackTarget = getBestTarget(\@skillCancelMonsters, $checkLOS, $canSnipe, $char, '') || + getBestTarget(\@looterMonsters, $checkLOS, $canSnipe, $char, '') || + getBestTarget(\@aggressives, $checkLOS, $canSnipe, $char, '') || + getBestTarget(\@partyMonsters, $checkLOS, $canSnipe, $char, '') || + getBestTarget(\@droppedMonsters, $checkLOS, $canSnipe, $char, '') || + getBestTarget(\@cleanMonsters, $checkLOS, $canSnipe, $char, ''); } # If an appropriate monster's found, attack it. If not, wait ai_attack_auto secs before searching again. @@ -3193,6 +3199,7 @@ sub processAutoAttack { ai_setSuspend(0); $char->attack($attackTarget); + $timeout{'ai_attack_auto'}{'time'} = 0; } else { $timeout{'ai_attack_auto'}{'time'} = time; } diff --git a/src/AI/Slave.pm b/src/AI/Slave.pm index 8a4342a8e2..8f63f19e55 100644 --- a/src/AI/Slave.pm +++ b/src/AI/Slave.pm @@ -14,9 +14,10 @@ use AI::SlaveAttack; use AI::Slave::Homunculus; use AI::Slave::Mercenary; -# Slave's commands and skills can only be used -# if the slave is within this range -use constant MAX_DISTANCE => 17; +use constant RATHENA_MASTER_RECALL_DELAY => 3.0; +use constant RATHENA_MASTER_RECALL_LOST_GRACE => 1.0; +use constant RATHENA_DEFAULT_AREA_SIZE => 14; +use constant RATHENA_MERCENARY_MASTER_RECALL_DISTANCE => 15; sub checkSkillOwnership {} @@ -153,7 +154,7 @@ sub isLost { sub mustRescue { my $slave = shift; - return 1 if ($config{$slave->{configPrefix}.'route_randomWalk_rescueWhenLost'}); + return 1 if ($config{$slave->{configPrefix}.'route_rescueWhenLost'}); return 0; } @@ -192,13 +193,47 @@ sub iterate { $slave->processIdleWalk; } +sub get_follow_standby_limit { + my $slave = shift; + + my $client_sight = $config{clientSight}; + $client_sight = 17 unless defined $client_sight && $client_sight > 0; + + return $client_sight * 2; +} + +sub get_master_recall_distance { + my $slave = shift; + + return RATHENA_MERCENARY_MASTER_RECALL_DISTANCE if $slave->isa('AI::Slave::Mercenary'); + return RATHENA_DEFAULT_AREA_SIZE; +} + +sub clear_follow_actions { + my $slave = shift; + + while (($slave->action eq 'move' || $slave->action eq 'route') && $slave->args->{isFollow}) { + $slave->dequeue; + } + + undef $slave->{move_retry}; +} + +sub clear_master_recall_state { + my $slave = shift; + delete $slave->{masterRecallStartedAt}; +} + sub processWasFound { my $slave = shift; - if ($slave->{isLost} && $slave->{master_dist} < MAX_DISTANCE) { - $slave->{lost_teleportToMaster_maxTries} = 0; + my $client_sight = $config{clientSight} || 17; + + if (($slave->{isLost} || $slave->{masterRecallStartedAt}) && $slave->{master_dist} < $client_sight) { + clear_master_recall_state($slave); + my $was_lost = $slave->{isLost}; $slave->{isLost} = 0; - warning TF("%s was rescued.\n", $slave), 'slave'; - if (AI::is('route') && AI::args()->{isSlaveRescue}) { + warning TF("%s was rescued.\n", $slave), 'slave' if $was_lost; + if ($was_lost && AI::is('route') && AI::args()->{isSlaveRescue}) { warning TF("Cleaning AI rescue sequence\n"), 'slave'; AI::dequeue() while (AI::is(qw/move route mapRoute/) && AI::args()->{isSlaveRescue}); } @@ -207,61 +242,165 @@ sub processWasFound { sub processTeleportToMaster { my $slave = shift; - if ( - !AI::args()->{mapChanged} - && $slave->{master_dist} >= MAX_DISTANCE - && timeOut($timeout{$slave->{ai_standby_timeout}}) - && !$slave->{isLost} - ) { - if (!$slave->{lost_teleportToMaster_maxTries} || $config{$slave->{configPrefix}.'lost_teleportToMaster_maxTries'} > $slave->{lost_teleportToMaster_maxTries}) { - $slave->clear('move', 'route'); - $slave->sendStandBy; - $slave->{lost_teleportToMaster_maxTries}++; - $timeout{$slave->{ai_standby_timeout}}{time} = time; - warning TF("%s trying to teleport to master (distance: %d) (re)try: %d\n", $slave, $slave->{master_dist}, $slave->{lost_teleportToMaster_maxTries}), 'slave'; - } else { - warning TF("%s is lost (distance: %d).\n", $slave, $slave->{master_dist}), 'slave'; - $slave->{isLost} = 1; - $timeout{$slave->{ai_standby_timeout}}{time} = time; + return if AI::args()->{mapChanged}; + + my $recall_distance = get_master_recall_distance($slave); + if ($slave->{master_dist} <= $recall_distance) { + clear_master_recall_state($slave); + return; + } + + $slave->{masterRecallStartedAt} = time unless $slave->{masterRecallStartedAt}; + + return if $slave->{isLost}; + + my $elapsed = time - $slave->{masterRecallStartedAt}; + my $recall_timeout = RATHENA_MASTER_RECALL_DELAY + RATHENA_MASTER_RECALL_LOST_GRACE; + return if $elapsed < $recall_timeout; + + $slave->{isLost} = 1; + warning TF("%s is lost (distance: %d).\n", $slave, $slave->{master_dist}), 'slave'; +} + +sub follow_route_needs_reset { + my ($slave, $args) = @_; + return 0 unless $slave && $args && $args->{isFollow}; + return 0 unless $args->{masterLastMoveTime} && $char->{pos_to}; + return 0 if $args->{masterLastMoveTime} == $char->{time_move}; + + if ($args->{masterLastMovePosTo}) { + return 1 + if $args->{masterLastMovePosTo}{x} != $char->{pos_to}{x} + || $args->{masterLastMovePosTo}{y} != $char->{pos_to}{y}; + } + + $args->{masterLastMoveTime} = $char->{time_move}; + $args->{masterLastMovePosTo} = { %{$char->{pos_to}} }; + return 0; +} + +sub start_follow { + my ($slave, $force_send_move) = @_; + return unless $slave && $char->{pos_to}; + + my $follow_mode = $config{$slave->{configPrefix}.'followMode'}; + $follow_mode = 1 if !defined $follow_mode || ($follow_mode != 1 && $follow_mode != 2); + + my $min_dist = $config{$slave->{configPrefix}.'followDistanceMin'}; + $min_dist = 3 unless defined $min_dist; + + my $must_route = $follow_mode == 2 || !$field->canMove($slave->{pos_to}, $char->{pos_to}); + + if ($must_route) { + $slave->route(undef, @{$char->{pos_to}}{qw(x y)}, noMapRoute => 1, avoidWalls => 0, randomFactor => 0, useManhattan => 0, distFromGoal => $min_dist, isFollow => 1); + if ($slave->action eq 'route' && $slave->args->{isFollow}) { + $slave->args->{masterLastMoveTime} = $char->{time_move}; + $slave->args->{masterLastMovePosTo} = { %{$char->{pos_to}} }; } + $slave->{lastFollowCommandTime} = time; + debug TF("%s follow route (distance: %d)\n", $slave, $slave->{master_dist}), 'slave'; + return 1; + } + + return 0 unless $force_send_move || timeOut($slave->{move_retry}, 0.5); + + $slave->{move_retry} = time; + # The default LUA uses sendSlaveStandBy() for the follow AI + # however, the server-side routing is very inefficient + # (e.g. can't route properly around obstacles and corners) + # so we make use of the sendSlaveMove() to make up for a more efficient routing + $slave->move($char->{pos_to}{x}, $char->{pos_to}{y}); + if ($slave->action eq 'move' && $slave->args) { + $slave->args->{isFollow} = 1; + $slave->args->{masterLastMoveTime} = $char->{time_move}; + $slave->args->{masterLastMovePosTo} = { %{$char->{pos_to}} }; } + $slave->{lastFollowCommandTime} = time; + debug TF("%s follow move (distance: %d)\n", $slave, $slave->{master_dist}), 'slave'; + return 1; +} + +sub reset_follow { + my ($slave, $args) = @_; + return unless $slave && $args; + + debug "$slave master $char has moved since we started the follow movement - Adjusting follow\n", 'slave'; + $slave->dequeue while ($slave->is("move", "route")); + + $args->{masterLastMoveTime} = $char->{time_move}; + $args->{masterLastMovePosTo} = { %{$char->{pos_to}} } if $char->{pos_to}; + undef $slave->{move_retry}; + start_follow($slave, 1); } sub processFollow { my $slave = shift; - if ( - (AI::action() eq "move" || AI::action() eq "route") - && !$char->{sitting} - && !AI::args()->{mapChanged} - && $slave->{master_dist} < MAX_DISTANCE - && ($slave->isIdle || $slave->{master_dist} > $config{$slave->{configPrefix}.'followDistanceMax'} || blockDistance($char->{pos_to}, $slave->{pos_to}) > $config{$slave->{configPrefix}.'followDistanceMax'}) - && (!defined $slave->findAction('route') || !$slave->args($slave->findAction('route'))->{isFollow}) - ) { - $slave->clear('move', 'route'); - if (!$field->canMove($slave->{pos_to}, $char->{pos_to})) { - $slave->route(undef, @{$char->{pos_to}}{qw(x y)}, noMapRoute => 1, avoidWalls => 0, randomFactor => 0, useManhattan => 1, isFollow => 1); - debug TF("%s follow route (distance: %d)\n", $slave, $slave->{master_dist}), 'slave'; - - } elsif (timeOut($slave->{move_retry}, 0.5)) { - # No update yet, send move request again. - # We do this every 0.5 secs - $slave->{move_retry} = time; - # NOTE: - # The default LUA uses sendSlaveStandBy() for the follow AI - # however, the server-side routing is very inefficient - # (e.g. can't route properly around obstacles and corners) - # so we make use of the sendSlaveMove() to make up for a more efficient routing - $slave->move($char->{pos_to}{x}, $char->{pos_to}{y}); - debug TF("%s follow move (distance: %d)\n", $slave, $slave->{master_dist}), 'slave'; + return if (AI::args()->{mapChanged}); + + my $max_dist = $config{$slave->{configPrefix}.'followDistanceMax'}; + $max_dist = 10 unless defined $max_dist; + + my $dist1 = $slave->{master_dist}; + my $dist2 = blockDistance($char->{pos_to}, $slave->{pos_to}); + + my $standby_limit = get_follow_standby_limit($slave); + my $should_standby = ($dist1 > $standby_limit && $dist2 > $standby_limit) ? 1 : 0; + + my $follow_action; + my $follow_args; + if ($slave->action eq 'move' && $slave->args->{isFollow}) { + $follow_action = 'move'; + $follow_args = $slave->args; + } elsif ($slave->action eq 'route' && $slave->args->{isFollow}) { + $follow_action = 'route'; + $follow_args = $slave->args; + } + my $is_following = defined $follow_action ? 1 : 0; + + if ($should_standby) { + if ($is_following) { + clear_follow_actions($slave); } + return unless timeOut($timeout{$slave->{ai_standby_timeout}}); + $timeout{$slave->{ai_standby_timeout}}{time} = time; + $slave->sendStandBy; + debug TF("%s standby (far from master: %d > %d)\n", $slave, $slave->{master_dist}, $standby_limit), 'slave'; + return; + } + + my $should_follow = ($dist1 > $max_dist || $dist2 > $max_dist) ? 1 : 0; + + if (!$should_follow && $is_following) { + # Don't drop mid follow + $should_follow = 1; + } + + if ($is_following && follow_route_needs_reset($slave, $follow_args)) { + reset_follow($slave, $follow_args); + return; + } + + if ($should_follow && !$is_following) { + start_follow($slave, 0); } } sub processIdleWalk { my $slave = shift; + my $max_dist = $config{$slave->{configPrefix}.'followDistanceMax'}; + $max_dist = 10 unless defined $max_dist; + + # Do not send idle standby/random-walk while follow is still active/recent. + my $master_is_moving = ($char->{pos} && $char->{pos_to} && ($char->{pos}{x} != $char->{pos_to}{x} || $char->{pos}{y} != $char->{pos_to}{y})) ? 1 : 0; + return if $master_is_moving; + my $standby_timeout = $timeout{$slave->{ai_standby_timeout}}{timeout} || 2; + return if $slave->{lastFollowCommandTime} && (time - $slave->{lastFollowCommandTime}) < $standby_timeout; + if ( $slave->isIdle - && $slave->{master_dist} <= MAX_DISTANCE + && $slave->{master_dist} <= $config{clientSight} + && $slave->{master_dist} <= $max_dist + && blockDistance($char->{pos_to}, $slave->{pos_to}) <= $max_dist && $config{$slave->{configPrefix}.'idleWalkType'} ) { # Standby @@ -375,9 +514,9 @@ sub processAutoAttack { return if defined $attackAuto && $attackAuto == -1; return if (!$field); - next unless ($slave->isIdle || $slave->is(qw/route/)); + return unless ($slave->isIdle || $slave->is(qw/route/)); - next unless ( + return unless ( AI::isIdle() || AI::is(qw(follow sitAuto attack skill_use)) || (AI::action() eq "route" && AI::action(1) eq "attack") || @@ -385,13 +524,13 @@ sub processAutoAttack { ($config{$slave->{configPrefix}.'attackAuto_duringItemsTake'} && AI::is(qw(take items_gather items_take))) || ($config{$slave->{configPrefix}.'attackAuto_duringRandomWalk'} && AI::is('route') && AI::args()->{isRandomWalk}) ); - next unless (timeOut($timeout{$slave->{ai_attack_auto_timeout}})); - next unless ($slave->{master_dist} <= $config{$slave->{configPrefix}.'followDistanceMax'}); + return unless (timeOut($timeout{$slave->{ai_attack_auto_timeout}})); + return unless ($slave->{master_dist} <= $config{$slave->{configPrefix}.'followDistanceMax'}); #next unless ((AI::action() ne "move" && AI::action() ne "route") || blockDistance($char->{pos_to}, $slave->{pos_to}) <= $config{$slave->{configPrefix}.'followDistanceMax'}); - next unless (!$config{$slave->{configPrefix}.'attackAuto_notInTown'} || !$field->isCity); - next unless (!$config{$slave->{configPrefix}.'attackAuto_notWhile_storageAuto'} || !AI::inQueue("storageAuto")); - next unless (!$config{$slave->{configPrefix}.'attackAuto_notWhile_buyAuto'} || !AI::inQueue("buyAuto")); - next unless (!$config{$slave->{configPrefix}.'attackAuto_notWhile_sellAuto'} || !AI::inQueue("sellAuto")); + return unless (!$config{$slave->{configPrefix}.'attackAuto_notInTown'} || !$field->isCity); + return unless (!$config{$slave->{configPrefix}.'attackAuto_notWhile_storageAuto'} || !AI::inQueue("storageAuto")); + return unless (!$config{$slave->{configPrefix}.'attackAuto_notWhile_buyAuto'} || !AI::inQueue("buyAuto")); + return unless (!$config{$slave->{configPrefix}.'attackAuto_notWhile_sellAuto'} || !AI::inQueue("sellAuto")); # If we're in tanking mode, only attack something if the person we're tanking for is on screen. my $foundTankee; @@ -507,9 +646,9 @@ sub processAutoAttack { # We define whether we should attack only monsters in LOS or not my $checkLOS = $config{$slave->{configPrefix}.'attackCheckLOS'}; my $canSnipe = $config{$slave->{configPrefix}.'attackCanSnipe'}; - $attackTarget = getBestTarget(\@aggressives, $checkLOS, $canSnipe) || - getBestTarget(\@partyMonsters, $checkLOS, $canSnipe) || - getBestTarget(\@cleanMonsters, $checkLOS, $canSnipe); + $attackTarget = getBestTarget(\@aggressives, $checkLOS, $canSnipe, $slave, $slave->{configPrefix}) || + getBestTarget(\@partyMonsters, $checkLOS, $canSnipe, $slave, $slave->{configPrefix}) || + getBestTarget(\@cleanMonsters, $checkLOS, $canSnipe, $slave, $slave->{configPrefix}); } # If an appropriate monster's found, attack it. If not, wait ai_attack_auto secs before searching again. diff --git a/src/AI/SlaveAttack.pm b/src/AI/SlaveAttack.pm index f00c177902..244241ec59 100644 --- a/src/AI/SlaveAttack.pm +++ b/src/AI/SlaveAttack.pm @@ -67,10 +67,27 @@ sub process { my $effectiveAttackMode = getEffectiveAttackOnRoute($routeArgs, $slave->{configPrefix}); my $assistParty = ($effectiveAttackMode >= 1 && $config{$slave->{configPrefix}.'attackAuto_party'}) ? 1 : 0; my $target_is_aggressive = is_aggressive_slave($slave, $target, undef, 0, $assistParty); + my $control = mon_control($target->{name}, $target->{nameID}); + + my %plugin_args; + $plugin_args{target} = $target; + $plugin_args{control} = $control; + $plugin_args{stage} = $stage; + $plugin_args{party} = $assistParty; + $plugin_args{target_is_aggressive} = $target_is_aggressive; + $plugin_args{actor} = $slave; + $plugin_args{configPrefix} = $slave->{configPrefix}; + $plugin_args{return} = 0; + Plugins::callHook('shouldDropTarget' => \%plugin_args); + if ($plugin_args{return}) { + giveUp($slave, $ataqArgs, $ID, 2); + return; + } + my $aggressiveType = ($effectiveAttackMode >= 2) ? 2 : 0; my @aggressives = $effectiveAttackMode >= 0 ? ai_slave_getAggressives($slave, $aggressiveType, $assistParty) : (); if ($config{$slave->{configPrefix}.'attackChangeTarget'} && !$target_is_aggressive && @aggressives) { - my $attackTarget = getBestTarget(\@aggressives, $config{$slave->{configPrefix}.'attackCheckLOS'}, $config{$slave->{configPrefix}.'attackCanSnipe'}); + my $attackTarget = getBestTarget(\@aggressives, $config{$slave->{configPrefix}.'attackCheckLOS'}, $config{$slave->{configPrefix}.'attackCanSnipe'}, $slave, $slave->{configPrefix}); if ($attackTarget) { $slave->sendAttackStop; $slave->dequeue while ($slave->inQueue("attack")); @@ -113,52 +130,9 @@ sub process { } # We're on route to the monster; check whether the monster has moved - if ($slave->args->{attackID} && timeOut($timeout{$slave->{ai_route_adjust_timeout}})) { - my $reset = 0; - if ($target->{type} ne 'Unknown') { - # Monster has moved; stop moving and let the attack AI readjust route - if ( - $ataqArgs->{monsterLastMoveTime} && - $ataqArgs->{monsterLastMoveTime} != $target->{time_move} - ) { - if ( - ($slave->args->{monsterLastMovePosTo}{x} == $target->{pos_to}{x} && $slave->args->{monsterLastMovePosTo}{y} == $target->{pos_to}{y}) - ) { - $slave->args->{monsterLastMoveTime} = $target->{time_move}; - $slave->args->{monsterLastMovePosTo}{x} = $target->{pos_to}{x}; - $slave->args->{monsterLastMovePosTo}{y} = $target->{pos_to}{y}; - } else { - debug "$slave target $target has moved since we started routing to it - Adjusting route\n", 'slave_attack'; - $reset = 1; - } - - # Master has moved; stop moving and let the attack AI readjust route - } elsif ( - $ataqArgs->{masterLastMoveTime} && - $ataqArgs->{masterLastMoveTime} != $char->{time_move} - ) { - if ( - ($slave->args->{masterLastMovePosTo}{x} == $char->{pos_to}{x} && $slave->args->{masterLastMovePosTo}{y} == $char->{pos_to}{y}) - ) { - $slave->args->{masterLastMoveTime} = $char->{time_move}; - $slave->args->{masterLastMovePosTo}{x} = $char->{pos_to}{x}; - $slave->args->{masterLastMovePosTo}{y} = $char->{pos_to}{y}; - } else { - debug "$slave master $char has moved since we started routing to target $target - Adjusting route\n", 'slave_attack'; - $reset = 1; - } - } - if ($reset) { - $slave->dequeue while ($slave->is("move", "route")); - $ataqArgs->{ai_attack_giveup}{time} = time; - $ataqArgs->{sentApproach} = 0; - undef $slave->args->{unstuck}{time}; - undef $slave->args->{avoiding}; - undef $slave->args->{move_start}; - } - } - - $timeout{$slave->{ai_route_adjust_timeout}}{time} = time; + if ($slave->args->{attackID} && approach_target_route_needs_reset($slave, $ataqArgs, $target)) { + reset_approach_for_moved_target($slave, $ataqArgs, $target); + return; } } @@ -202,6 +176,55 @@ sub shouldGiveUp { return !$config{$slave->{configPrefix}.'attackNoGiveup'} && (timeOut($args->{ai_attack_giveup}) || $args->{unstuck}{count} > 5) } +sub approach_target_route_needs_reset { + my ($slave, $args, $target) = @_; + return 0 unless $slave && $args && $target; + return 0 if $target->{type} eq 'Unknown'; + return 0 unless $args->{sentApproach}; + + if ($args->{monsterLastMoveTime} && $args->{monsterLastMoveTime} != $target->{time_move} && $target->{pos_to}) { + if ($args->{monsterLastMovePosTo}) { + return 1 + if $args->{monsterLastMovePosTo}{x} != $target->{pos_to}{x} + || $args->{monsterLastMovePosTo}{y} != $target->{pos_to}{y}; + } + + $args->{monsterLastMoveTime} = $target->{time_move}; + $args->{monsterLastMovePosTo} = { %{$target->{pos_to}} }; + } + + if ($args->{masterLastMoveTime} && $args->{masterLastMoveTime} != $char->{time_move} && $char->{pos_to}) { + if ($args->{masterLastMovePosTo}) { + return 1 + if $args->{masterLastMovePosTo}{x} != $char->{pos_to}{x} + || $args->{masterLastMovePosTo}{y} != $char->{pos_to}{y}; + } + + $args->{masterLastMoveTime} = $char->{time_move}; + $args->{masterLastMovePosTo} = { %{$char->{pos_to}} }; + } + + return 0; +} + +sub reset_approach_for_moved_target { + my ($slave, $args, $target) = @_; + return unless $slave && $args && $target; + + debug "$slave target $target or master $char has moved since we started routing to it - Adjusting route\n", 'slave_attack'; + $slave->dequeue while ($slave->is("move", "route")); + + $args->{ai_attack_giveup}{time} = time; + $args->{sentApproach} = 0; + $args->{monsterLastMoveTime} = $target->{time_move}; + $args->{monsterLastMovePosTo} = { %{$target->{pos_to}} } if $target->{pos_to}; + $args->{masterLastMoveTime} = $char->{time_move}; + $args->{masterLastMovePosTo} = { %{$char->{pos_to}} } if $char->{pos_to}; + undef $args->{unstuck}{time}; + undef $args->{avoiding}; + undef $args->{move_start}; +} + sub giveUp { my ($slave, $args, $ID, $LOS) = @_; my $target = Actor::get($ID); @@ -218,6 +241,9 @@ sub giveUp { if ($config{$slave->{configPrefix}.'teleportAuto_dropTarget'}) { message TF("Teleport due to dropping %s attack target\n", $slave), 'teleport'; ai_useTeleport(1); + } elsif ($config{$slave->{configPrefix}.'teleportAuto_dropTargetEngaged'} && ($target->{sentAttack} || $target->{engaged})) { + message TF("Teleport due to dropping %s attack target already engaged\n", $slave), 'teleport'; + ai_useTeleport(1); } } @@ -328,12 +354,15 @@ sub main { my $ID = $args->{ID}; my $target = Actor::get($ID); - my $myPos = $slave->{pos_to}; + my $myPosTo = $slave->{pos_to}; my $monsterPos = $target->{pos_to}; - my $monsterDist = blockDistance($myPos, $monsterPos); + my $monsterDist = blockDistance($myPosTo, $monsterPos); - my $realMyPos = calcPosFromPathfinding($field, $slave); - my $realMonsterPos = calcPosFromPathfinding($field, $target); + my $extra_time = exists $timeout{'ai_route_position_prediction_delay'}{'timeout'} ? $timeout{'ai_route_position_prediction_delay'}{'timeout'} : 0.1; + $extra_time = 0 unless defined $extra_time; + + my $realMyPos = calcPosFromPathfinding($field, $slave, $extra_time); + my $realMonsterPos = calcPosFromPathfinding($field, $target, $extra_time); my $realMonsterDist = blockDistance($realMyPos, $realMonsterPos); my $clientDist = getClientDist($realMyPos, $realMonsterPos); @@ -342,10 +371,10 @@ sub main { #my $realMasterDistToSlave = blockDistance($realMasterPos, $realMyPos); #my $realMasterDistToTarget = blockDistance($realMasterPos, $realMonsterPos); - if (!exists $args->{first_run}) { - $args->{first_run} = 1; - } elsif ($args->{first_run} == 1) { - $args->{first_run} = 0; + if (!exists $args->{firstLoop}) { + $args->{firstLoop} = 1; + } else { + $args->{firstLoop} = 0; } #my $failed_to_attack_packet_recv = 0; @@ -359,26 +388,40 @@ sub main { # $args->{temporary_extra_range} = 0; #} + my $dmgToSlave = $target->{dmgToPlayer}{$slave->{ID}} || 0; + my $missedToSlave = $target->{missedToPlayer}{$slave->{ID}} || 0; + my $castOnToSlave = $target->{castOnToPlayer}{$slave->{ID}} || 0; + my $dmgFromSlave = $target->{dmgFromPlayer}{$slave->{ID}} || 0; + my $missedFromSlave = $target->{missedFromPlayer}{$slave->{ID}} || 0; + + my $hitYou = ((defined $args->{dmgToYou_last} && $args->{dmgToYou_last} != $dmgToSlave) || (defined $args->{missedYou_last} && $args->{missedYou_last} != $missedToSlave)) ? 1 : 0; + my $castOnYou = (defined $args->{castOnToYou_last} && $args->{castOnToYou_last} != $castOnToSlave) ? 1 : 0; + my $youHitTarget = ((defined $args->{dmgFromYou_last} && $args->{dmgFromYou_last} != $dmgFromSlave) || (defined $args->{missedFromYou_last} && $args->{missedFromYou_last} != $missedFromSlave)) ? 1 : 0; + + # Hack - TODO: Fix me - If the homunculus dies trying to kill a monster and is resurrected still next to that monster it will think that it is still hitting the mob, this avoids that behaviour + if ($youHitTarget && $args->{firstLoop}) { + $youHitTarget = 0; + } + + if ($hitYou || $castOnYou || $youHitTarget || ($args->{firstLoop} && ($dmgToSlave || $missedToSlave || $dmgFromSlave || $missedFromSlave || $castOnToSlave))) { + $target->{engaged} = 1 if (!exists $target->{engaged} || !$target->{engaged}); + } + # If the damage numbers have changed, update the giveup time so we don't timeout - if ($args->{dmgToYou_last} != $target->{dmgToPlayer}{$slave->{ID}} - || $args->{missedYou_last} != $target->{missedToPlayer}{$slave->{ID}} - || $args->{dmgFromYou_last} != $target->{dmgFromPlayer}{$slave->{ID}}) { + if ($args->{dmgToYou_last} != $dmgToSlave + || $args->{missedYou_last} != $missedToSlave + || $args->{castOnToYou_last} != $castOnToSlave + || $args->{dmgFromYou_last} != $dmgFromSlave + || $args->{missedFromYou_last} != $missedFromSlave) { $args->{ai_attack_giveup}{time} = time; debug "Update slave attack giveup time\n", 'slave_attack', 2; } - - my $hitYou = ($args->{dmgToYou_last} != $target->{dmgToPlayer}{$slave->{ID}} || $args->{missedYou_last} != $target->{missedToPlayer}{$slave->{ID}}); - my $youHitTarget = ($args->{dmgFromYou_last} != $target->{dmgFromPlayer}{$slave->{ID}}); - - # Hack - TODO: Fix me - If the homunculus dies trying to kill a monster and is resurrected still next to that monster it will think that it is still hitting the mob, this avoids that behaviour - if ($youHitTarget && $args->{first_run}) { - $youHitTarget = 0; - } - - $args->{dmgToYou_last} = $target->{dmgToPlayer}{$slave->{ID}}; - $args->{missedYou_last} = $target->{missedToPlayer}{$slave->{ID}}; - $args->{dmgFromYou_last} = $target->{dmgFromPlayer}{$slave->{ID}}; - $args->{missedFromYou_last} = $target->{missedFromPlayer}{$slave->{ID}}; + + $args->{dmgToYou_last} = $dmgToSlave; + $args->{missedYou_last} = $missedToSlave; + $args->{castOnToYou_last} = $castOnToSlave; + $args->{dmgFromYou_last} = $dmgFromSlave; + $args->{missedFromYou_last} = $missedFromSlave; delete $args->{attackMethod}; # $target->{dmgFromPlayer}{$slave->{ID}} - $target->{dmgTo} @@ -463,16 +506,22 @@ sub main { # Here we check if we have finished moving to the meeting position to attack our target, only checks this if attackWaitApproachFinish is set to 1 in config # If so sets sentApproach to 0 - if ( - $config{$slave->{configPrefix}."attackWaitApproachFinish"} && - ($canAttack == 0 || $canAttack == -1) && - $args->{sentApproach} - ) { - if (!timeOut($slave->{time_move}, $slave->{time_move_calc})) { - debug TF("[Slave] [Out of Range - Still Approaching - Waiting] %s (%d %d), target %s (%d %d), distance %d, maxDistance %d.\n", $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}), 'ai_attack'; + if ($args->{sentApproach}) { + if (approach_target_route_needs_reset($slave, $args, $target)) { + reset_approach_for_moved_target($slave, $args, $target); return; - } else { - debug TF("[Slave] [Out of Range - Ended Approaching] %s (%d %d), target %s (%d %d), distance %d, maxDistance %d.\n", $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}), 'ai_attack'; + } + + if ($realMyPos->{x} == $myPosTo->{x} && $realMyPos->{y} == $myPosTo->{y}) { + debug TF("[Slave] [Ended Approaching] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d.\n", $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}), 'ai_attack'; + $args->{sentApproach} = 0; + + } elsif ($config{$slave->{configPrefix}."attackWaitApproachFinish"}) { + debug TF("[Slave] [attackWaitApproachFinish - Waiting] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d.\n", $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}), 'ai_attack'; + return; + + } elsif ($canAttack == 2) { + debug TF("[Slave] [Approaching - Can now attack] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d.\n", $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}), 'ai_attack'; $args->{sentApproach} = 0; } } @@ -522,7 +571,7 @@ sub main { delete $args->{ai_attack_failed_give_up}{time}; warning T("[$slave] Unable to determine a attackMethod (check attackUseWeapon and Skills blocks), dropping target.\n"), "ai_attack"; $found_action = 1; - giveUp($args, $ID, 0); + giveUp($slave, $args, $ID, 0); } } @@ -570,6 +619,21 @@ sub main { $found_action = 1; } + if ( + !$found_action && + $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'} && + ($canAttack == 0 || $canAttack == -1) && + !$hitTarget_when_not_possible + ) { + my $futureMonsterPos = calcPosFromPathfinding($field, $target, ($extra_time + $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'})); + my $futurecanAttack = canAttack($field, $realMyPos, $futureMonsterPos, $config{$slave->{configPrefix}.'attackCanSnipe'}, $args->{attackMethod}{maxDistance}, $config{clientSight}); + if ($futurecanAttack) { + warning TF("[SlaveAttack] %s currently cannot attack, but will be able to in up to [%s secs], waiting. %s (%d %d), target %s (%d %d) [(%d %d) -> (%d %d)])\n", + $slave, $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'}, $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}), 'ai_attack'; + $found_action = 1; + } + } + # Here we decide what to do with a mob which is out of range or we have no LOS to if ( !$found_action && @@ -590,12 +654,9 @@ sub main { $args->{move_start} = time; $args->{monsterLastMoveTime} = $target->{time_move}; - $args->{monsterLastMovePosTo}{x} = $target->{pos_to}{x}; - $args->{monsterLastMovePosTo}{y} = $target->{pos_to}{y}; - + $args->{monsterLastMovePosTo} = { %{$target->{pos_to}} } if $target->{pos_to}; $args->{masterLastMoveTime} = $char->{time_move}; - $args->{masterLastMovePosTo}{x} = $char->{pos_to}{x}; - $args->{masterLastMovePosTo}{y} = $char->{pos_to}{y}; + $args->{masterLastMovePosTo} = { %{$char->{pos_to}} } if $char->{pos_to}; $args->{sentApproach} = 1; my $sendAttackWithMove = 0; @@ -630,6 +691,7 @@ sub main { # Attack the target. In case of tanking, only attack if it hasn't been hit once. if (!$args->{firstAttack}) { $args->{firstAttack} = 1; + $target->{sentAttack} = 1; debug "[Slave $slave] Ready to attack target $target ($realMonsterPos->{x} $realMonsterPos->{y}) ($realMonsterDist blocks away); we're at ($realMyPos->{x} $realMyPos->{y})\n", "ai_attack"; } @@ -640,7 +702,7 @@ sub main { # Our recorded position might be out of sync, so try to unstuck $args->{unstuck}{time} = time; debug("$slave attack - trying to unstuck\n", 'slave_attack'); - $slave->move($myPos->{x}, $myPos->{y}); + $slave->move($myPosTo->{x}, $myPosTo->{y}); $args->{unstuck}{count}++; } @@ -690,7 +752,7 @@ sub main { $ai_v{"attackSkillSlot_${slot}_time"} = time; $ai_v{"attackSkillSlot_${slot}_target_time"}{$ID} = time; - $args->{attackSkillSlot_attempts}{$i}++; + $args->{attackSkillSlot_attempts}{$slot}++; ai_setSuspend(0); my $skill = new Skill(auto => $config{"attackSkillSlot_$slot"}); diff --git a/src/AI/SlaveManager.pm b/src/AI/SlaveManager.pm index ab43a13676..835beb26fa 100644 --- a/src/AI/SlaveManager.pm +++ b/src/AI/SlaveManager.pm @@ -26,7 +26,6 @@ sub addSlave { $actor->{ai_attack_timeout} = 'ai_homunculus_attack'; $actor->{ai_attack_auto_timeout} = 'ai_homunculus_attack_auto'; $actor->{ai_check_monster_auto} = 'ai_homunculus_check_monster_auto'; - $actor->{ai_route_adjust_timeout} = 'ai_homunculus_route_adjust'; $actor->{ai_attack_main} = 'ai_homunculus_attack_main'; $actor->{ai_standby_timeout} = 'ai_homunculus_standby'; $actor->{ai_dance_attack_melee_timeout} = 'ai_homunculus_dance_attack_melee'; @@ -43,7 +42,6 @@ sub addSlave { $actor->{ai_attack_timeout} = 'ai_mercenary_attack'; $actor->{ai_attack_auto_timeout} = 'ai_mercenary_attack_auto'; $actor->{ai_check_monster_auto} = 'ai_mercenary_check_monster_auto'; - $actor->{ai_route_adjust_timeout} = 'ai_mercenary_route_adjust'; $actor->{ai_attack_main} = 'ai_mercenary_attack_main'; $actor->{ai_standby_timeout} = 'ai_mercenary_standby'; $actor->{ai_dance_attack_melee_timeout} = 'ai_mercenary_dance_attack_melee'; @@ -115,7 +113,7 @@ sub mustStopForAttack { foreach my $slave (values %{$char->{slaves}}) { if ($slave && %{$slave} && $slave->isa ('AI::Slave')) { - return $slave if ($slave->action eq "attack" && $config{$slave->{configPrefix}.'route_randomWalk_stopDuringAttack'}); + return $slave if ($slave->action eq "attack" && $config{$slave->{configPrefix}.'route_stopDuringAttack'}); } } return undef; @@ -139,7 +137,7 @@ sub mustWaitMinDistance { foreach my $slave (values %{$char->{slaves}}) { if ($slave && %{$slave} && $slave->isa ('AI::Slave')) { my $dist = $slave->blockDistance_master; - return $slave if ($config{$slave->{configPrefix}.'route_randomWalk_waitMinDistance'} && $dist > $config{$slave->{configPrefix}.'route_randomWalk_waitMinDistance'}); + return $slave if ($config{$slave->{configPrefix}.'route_waitMinDistance'} && $dist > $config{$slave->{configPrefix}.'route_waitMinDistance'}); } } return undef; diff --git a/src/Commands.pm b/src/Commands.pm index 149cf7097c..d607e74c17 100644 --- a/src/Commands.pm +++ b/src/Commands.pm @@ -2929,6 +2929,7 @@ sub cmdSlave { } else { error T("Error: Unknown command in cmdSlave\n"); } + return unless $slave; my $string = $cmd; if ($slave->isa("AI::Slave::Homunculus") && $slave->{homunculus_info}{vaporized}) { diff --git a/src/Field.pm b/src/Field.pm index bca8c57d47..0aa65f4335 100644 --- a/src/Field.pm +++ b/src/Field.pm @@ -482,7 +482,7 @@ sub canMove { # This value is actually set at # hercules conf\map\battle\client.conf max_walk_path (which is by default 17, can be higher) - my $maxWalkPath = $config{maxWalkPathDistance} || 17; + my $maxWalkPath = $config{maxUnobstructedWalkPathDistance} || 17; if ($dist > $maxWalkPath) { return 0; } @@ -493,8 +493,9 @@ sub canMove { return 1; } + my $maxObsWalkPath = $config{maxObstructedWalkPathDistance} || 14; # If there are obstacles and the path is walkable the max solution dist acceptable is 14 (double check to save time) - if ($dist > 14) { + if ($dist > $maxObsWalkPath) { return 0; } diff --git a/src/Misc.pm b/src/Misc.pm index e989ec911b..7b2b689ca0 100644 --- a/src/Misc.pm +++ b/src/Misc.pm @@ -242,6 +242,7 @@ our @EXPORT = ( solveMSG get_lockMap_cell absunit + print_callers autoNpcTalk/, # Npc buy and sell @@ -3085,7 +3086,12 @@ sub meetingPosition { my $allspots_count = scalar @candidate_spots; my %prohibitedSpots; - foreach my $prohibited_actor (@$playersList, @$monstersList, @$npcsList, @$petsList, @$slavesList, @$elementalsList) { + foreach my $prohibited_actor (@$playersList, @$monstersList, @$npcsList, @$petsList, @$slavesList, @$elementalsList, @$portalsList) { + next unless ($prohibited_actor->{pos_to}); + next unless (defined $prohibited_actor->{ID}); + next if ($prohibited_actor->{ID} eq $target->{ID}); + next if ($prohibited_actor->{ID} eq $actor->{ID}); + next if ($masterPos && $master && defined $master->{ID} && $prohibited_actor->{ID} eq $master->{ID}); $prohibitedSpots{$prohibited_actor->{pos_to}{x}}{$prohibited_actor->{pos_to}{y}} = 1; } @@ -4159,24 +4165,28 @@ sub _targetWillLeaveClientSightSoon { } ## -# getBestTarget(possibleTargets, attackCheckLOS, $attackCanSnipe) +# getBestTarget(possibleTargets, attackCheckLOS, $attackCanSnipe, $actor, $configPrefix) # possibleTargets: reference to an array of monsters' IDs # attackCheckLOS: if set, non-LOS monsters are checked up # # Returns ID of the best target sub getBestTarget { - my ($possibleTargets, $attackCheckLOS, $attackCanSnipe) = @_; + my ($possibleTargets, $attackCheckLOS, $attackCanSnipe, $actor, $configPrefix) = @_; if (!$possibleTargets) { return; } + $actor ||= $char; + $configPrefix ||= ''; + my $portalDist = $config{'attackMinPortalDistance'} || 4; my $playerDist = $config{'attackMinPlayerDistance'} || 1; my @noLOSMonsters; my @noLOSMonsters_pos; # TODO: Is there any situation where we should use calcPosFromPathfinding or calcPosFromTime here? - my $myPos = calcPosFromPathfinding($field, $char); + my $actorPos = calcPosFromPathfinding($field, $actor); + my ($highestPri, $smallestDist, $bestTarget); # First of all we check monsters in LOS, then the rest of monsters @@ -4185,16 +4195,18 @@ sub getBestTarget { $plugin_args{possibleTargets} = $possibleTargets; $plugin_args{attackCheckLOS} = $attackCheckLOS; $plugin_args{attackCanSnipe} = $attackCanSnipe; + $plugin_args{actor} = $actor; + $plugin_args{configPrefix} = $configPrefix; $plugin_args{return} = 0; Plugins::callHook('getBestTarget' => \%plugin_args); foreach (@{$possibleTargets}) { my $monster = $monsters{$_}; # TODO: Is there any situation where we should use calcPosFromPathfinding or calcPosFromTime here? - my $pos = calcPosFromPathfinding($field, $monster); + my $targetPos = calcPosFromPathfinding($field, $monster); - next if (positionNearPlayer($pos, $playerDist) - || positionNearPortal($pos, $portalDist) + next if (positionNearPlayer($targetPos, $playerDist) + || positionNearPortal($targetPos, $portalDist) ); my $control = mon_control($monster->{name},$monster->{nameID}); @@ -4211,14 +4223,14 @@ sub getBestTarget { next if (_targetWillLeaveClientSightSoon($char, $monster)); - if (!$field->checkLOS($myPos, $pos, $attackCanSnipe)) { + if (!$field->checkLOS($actorPos, $targetPos, $attackCanSnipe)) { push(@noLOSMonsters, $_); - push(@noLOSMonsters_pos, $pos); + push(@noLOSMonsters_pos, $targetPos); next; } my $name = lc $monster->{name}; - my $dist = adjustedBlockDistance($myPos, $pos); + my $dist = adjustedBlockDistance($actorPos, $targetPos); my $priority = $priority{$name} ? $priority{$name} : 0; if (!defined($bestTarget) || ($priority > $highestPri)) { @@ -4234,8 +4246,8 @@ sub getBestTarget { } } if ($attackCheckLOS && !$bestTarget && scalar(@noLOSMonsters) > 0) { - my $pathfinding = new PathFinding; - my ($min_pathfinding_x, $min_pathfinding_y, $max_pathfinding_x, $max_pathfinding_y) = $field->getSquareEdgesFromCoord($myPos, $config{attackRouteMaxPathDistance}); + require Task::Route; + my $solution; foreach my $index (0..$#noLOSMonsters) { # The most optimal solution is to include the path lenghts' comparison, however it will take @@ -4243,36 +4255,31 @@ sub getBestTarget { my $monster = $monsters{$noLOSMonsters[$index]}; # TODO: Is there any situation where we should use calcPosFromPathfinding or calcPosFromTime here? - my $pos = $noLOSMonsters_pos[$index]; + my $targetPos = $noLOSMonsters_pos[$index]; # avoid get targets away from attackRouteMaxPathDistance - next if(blockDistance($myPos, $pos) >= $config{attackRouteMaxPathDistance}); - - $pathfinding->reset( - start => $myPos, - dest => $pos, - field => $field, - avoidWalls => 0, - randomFactor => 0, - useManhattan => 0, - min_x => $min_pathfinding_x, - max_x => $max_pathfinding_x, - min_y => $min_pathfinding_y, - max_y => $max_pathfinding_y - ); - my $dist = $pathfinding->runcount; - if ($dist <= 0 || $dist > $config{attackRouteMaxPathDistance}) { - $monster->{attack_failedLOS} = time; + next if(blockDistance($actorPos, $targetPos) >= $config{attackRouteMaxPathDistance}); + + @{$solution} = (); + unless (Task::Route->getRoute($solution, $field, $actorPos, $targetPos, $config{'route_avoidWalls'}, 0, 0, 1, 1)) { + next; + } + + if (scalar @{$solution} == 0) { next; } + my $dist = scalar @{$solution}; + my $name = lc $monster->{name}; my $priority = $priority{$name} ? $priority{$name} : 0; + if (!defined($bestTarget) || ($priority > $highestPri)) { $highestPri = $priority; $smallestDist = $dist; $bestTarget = $noLOSMonsters[$index]; } + if ((!defined($bestTarget) || $priority == $highestPri) && (!defined($smallestDist) || $dist < $smallestDist)) { $highestPri = $priority; diff --git a/src/Network/Receive.pm b/src/Network/Receive.pm index fddab89635..0382a8cf37 100644 --- a/src/Network/Receive.pm +++ b/src/Network/Receive.pm @@ -2086,8 +2086,9 @@ sub actor_display { return; } + my $maxWalkPath = $config{maxUnobstructedWalkPathDistance} || 17; if ( ($coordsFrom{x} == 0 && $coordsFrom{y} == 0) || ($coordsTo{x} == 0 && $coordsTo{y} == 0) || - (blockDistance(\%coordsFrom, \%coordsTo) > $config{clientSight}) ) { + (blockDistance(\%coordsFrom, \%coordsTo) > $maxWalkPath) ) { warning TF("Ignoring bugged actor moved packet (%s) (%d, %d)->(%d, %d)\n", $args->{switch}, $coordsFrom{x}, $coordsFrom{y}, $coordsTo{x}, $coordsTo{y}); # seems this is just a position bug, lets just ignore the change in position # $actor->{avoid} = 1; @@ -8273,12 +8274,13 @@ sub actor_movement_interrupted { if ($actor->isa('Actor::You') || $actor->isa('Actor::Player')) { $actor->{sitting} = 0; } + if ($actor->isa('Actor::You')) { debug "Movement interrupted, your coordinates: $coords{x}, $coords{y}\n", "parseMsg_move"; AI::clear("move"); - } - if ($char->{homunculus} && $char->{homunculus}{ID} eq $actor->{ID}) { - AI::clear("move"); + + } elsif (($char->{homunculus} && $char->{homunculus}{ID} eq $actor->{ID}) || ($char->{mercenary} && $char->{mercenary}{ID} eq $actor->{ID})) { + debug TF("[%s] Movement interrupted, coordinates: %s %s\n", $actor, $coords{x}, $coords{y}), "parseMsg_move"; } } diff --git a/src/Task/CalcMapRoute.pm b/src/Task/CalcMapRoute.pm index 929bc7e7f0..d50058e905 100644 --- a/src/Task/CalcMapRoute.pm +++ b/src/Task/CalcMapRoute.pm @@ -571,9 +571,6 @@ sub populateOpenListWithWarpToSaveMap { my ($current_map) = split / /, $from_node, 2; return unless $self->isWarpToSaveMapAllowedOnMap($current_map); - my @warpItemCandidates = $self->getWarpItemCandidates(); - return unless @warpItemCandidates; - return unless ($self->isWarpToSaveMapMinDistanceReached()); my $saveMapDestination = $self->resolveSaveMapDestination(); return unless ($saveMapDestination); diff --git a/src/Task/Route.pm b/src/Task/Route.pm index 9cc8ec9d0e..6e3e30a758 100644 --- a/src/Task/Route.pm +++ b/src/Task/Route.pm @@ -720,10 +720,10 @@ sub iterate { return; } - if ($self->{actor}->isa('Actor::You') && $self->{isRandomWalk} && $self->{actor}{slaves}) { + if ($self->{actor}->isa('Actor::You') && $self->{actor}{slaves}) { my $slave = AI::SlaveManager::mustWaitMinDistance(); if (defined $slave) { - debug TF("Waiting for slave %s before next randomWalk step.\n", $slave), 'route', 2; + debug TF("Waiting for slave %s before next step.\n", $slave), 'route', 2; return; } }