diff --git a/control/config.txt b/control/config.txt index 578078ea1b..1967baae90 100644 --- a/control/config.txt +++ b/control/config.txt @@ -219,7 +219,6 @@ lockMap_randY route_escape_reachedNoPortal 1 route_escape_randomWalk 1 route_escape_shout -route_avoidWalls 1 route_randomWalk 1 route_randomWalk_inLockOnly 0 route_randomWalk_inTown 0 @@ -237,6 +236,7 @@ route_removeMissingPortals_NPC 1 route_removeMissingPortals 0 route_tryToGuessMissingPortalByDistance 1 route_reAddMissingPortals 1 +route_avoidWalls 1 route_randomFactor 0 # Eden Group Headquarters exit portal. @@ -1044,3 +1044,29 @@ logToFile_Errors logToFile_Messages logToFile_Warnings history_max 50 + +avoidObstacles_enable_move 0 +avoidObstacles_enable_remove 1 +avoidObstacles_enable_avoid_portals 1 +avoidObstacles_adjust_route_step 1 +avoidObstacles_weight_limit 65000 + +avoidObstaclesDefaultPortals { + enabled 1 + penalty_dist 1000, 1000, 1000, 20 + danger_dist 10, 10, 10, 1 + prohibited_dist 2 + drop_target_when_near_dist 3 + drop_destination_when_near_dist 3 +} + +avoidObstaclesMonster 1368 { + enabled 1 + penalty_dist 20000, 20000, 20000, 20000, 30, 7 + danger_dist 10, 10, 10, 10, 1 + prohibited_dist -1 + drop_target_when_near_dist 4 + drop_destination_when_near_dist 4 +} + + diff --git a/control/timeouts.txt b/control/timeouts.txt index a364a54747..6b8294d666 100644 --- a/control/timeouts.txt +++ b/control/timeouts.txt @@ -49,9 +49,9 @@ ai_attack_auto 0.5 ai_homunculus_attack_auto 0.5 ai_mercenary_attack_auto 0.5 -ai_attack_route_adjust 0.3 -ai_homunculus_route_adjust 0.3 -ai_mercenary_route_adjust 0.3 +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 @@ -219,5 +219,7 @@ patchserver 120 # Time to wait before load map in xKore mode ai_clientSuspend 1 -meetingPosition_extra_time_actor 0.2 -meetingPosition_extra_time_target 0.5 +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 diff --git a/fields/tools/compareFields/compare.pl b/fields/tools/compareFields/compare.pl new file mode 100644 index 0000000000..0dff7f71a2 --- /dev/null +++ b/fields/tools/compareFields/compare.pl @@ -0,0 +1,254 @@ +#!/usr/bin/perl +use strict; +use warnings; + +use constant { + TILE_NOWALK => 0, + TILE_WALK => 1, + TILE_SNIPE => 2, + TILE_WATER => 4, + TILE_CLIFF => 8, +}; + +my $DEFAULT_LIMIT = 200; + +main(); + +sub main { + my %options = parse_args(@ARGV); + my $left = read_fld2($options{left}); + my $right = read_fld2($options{right}); + + print "Comparing '$left->{path}' with '$right->{path}'\n"; + print "Left map : $left->{width} x $left->{height}\n"; + print "Right map: $right->{width} x $right->{height}\n"; + + if ($left->{width} != $right->{width} || $left->{height} != $right->{height}) { + print "Map dimensions differ; comparing only the overlapping area.\n"; + } + + my $comparison = compare_maps($left, $right); + print_comparison($comparison, $options{limit}); +} + +sub parse_args { + my @args = @_; + my %options = ( + limit => $DEFAULT_LIMIT, + ); + + while (@args) { + my $arg = shift @args; + if ($arg eq '--limit') { + die usage() unless @args; + my $limit = shift @args; + die "Invalid --limit value '$limit'.\n" . usage() unless $limit =~ /^\d+$/; + $options{limit} = $limit; + } elsif (!$options{left}) { + $options{left} = $arg; + } elsif (!$options{right}) { + $options{right} = $arg; + } else { + die "Unexpected argument '$arg'.\n" . usage(); + } + } + + die usage() unless $options{left} && $options{right}; + return %options; +} + +sub usage { + return "Usage: perl compare.pl [--limit N]\n"; +} + +sub read_fld2 { + my ($path) = @_; + open my $fh, '<', $path or die "Cannot open $path for reading: $!\n"; + binmode $fh; + + my $raw = do { local $/; <$fh> }; + close $fh; + + die "$path is too small to be a valid .fld2 file.\n" unless defined $raw && length($raw) >= 4; + + my ($width, $height) = unpack('v2', substr($raw, 0, 4)); + my $expected_tiles = $width * $height; + my $tile_data = substr($raw, 4); + my $actual_tiles = length($tile_data); + + die "$path has incomplete tile data. Expected $expected_tiles bytes, got $actual_tiles.\n" + if $actual_tiles < $expected_tiles; + + if ($actual_tiles > $expected_tiles) { + print "Warning: $path contains " . ($actual_tiles - $expected_tiles) . " trailing bytes after tile data.\n"; + $tile_data = substr($tile_data, 0, $expected_tiles); + } + + return { + path => $path, + width => $width, + height => $height, + tiles => [unpack('C*', $tile_data)], + }; +} + +sub compare_maps { + my ($left, $right) = @_; + my $compare_width = min($left->{width}, $right->{width}); + my $compare_height = min($left->{height}, $right->{height}); + + my @changes; + my %transition_counts; + my %row_counts; + my ($min_x, $min_y, $max_x, $max_y); + + for my $y (0 .. $compare_height - 1) { + for my $x (0 .. $compare_width - 1) { + my $left_value = tile_at($left, $x, $y); + my $right_value = tile_at($right, $x, $y); + next if $left_value == $right_value; + + push @changes, { + x => $x, + y => $y, + left => $left_value, + right => $right_value, + }; + + $transition_counts{"$left_value->$right_value"}++; + $row_counts{$y}++; + + $min_x = $x if !defined $min_x || $x < $min_x; + $max_x = $x if !defined $max_x || $x > $max_x; + $min_y = $y if !defined $min_y || $y < $min_y; + $max_y = $y if !defined $max_y || $y > $max_y; + } + } + + my $left_extra = count_extra_cells($left, $compare_width, $compare_height); + my $right_extra = count_extra_cells($right, $compare_width, $compare_height); + + return { + left => $left, + right => $right, + compare_width => $compare_width, + compare_height => $compare_height, + changes => \@changes, + transition_counts => \%transition_counts, + row_counts => \%row_counts, + bounds => defined $min_x ? { min_x => $min_x, min_y => $min_y, max_x => $max_x, max_y => $max_y } : undef, + left_extra => $left_extra, + right_extra => $right_extra, + }; +} + +sub count_extra_cells { + my ($map, $compare_width, $compare_height) = @_; + my $extra = 0; + + for my $y (0 .. $map->{height} - 1) { + for my $x (0 .. $map->{width} - 1) { + next if $x < $compare_width && $y < $compare_height; + $extra++; + } + } + + return $extra; +} + +sub tile_at { + my ($map, $x, $y) = @_; + return $map->{tiles}[($y * $map->{width}) + $x]; +} + +sub print_comparison { + my ($comparison, $limit) = @_; + my $changes = $comparison->{changes}; + my $change_count = scalar @{$changes}; + + if (!$change_count && !$comparison->{left_extra} && !$comparison->{right_extra}) { + print "The two maps are identical.\n"; + return; + } + + print "Compared area: $comparison->{compare_width} x $comparison->{compare_height}\n"; + print "Changed cells in overlap: $change_count\n"; + + if ($comparison->{bounds}) { + my $bounds = $comparison->{bounds}; + print "Change bounds: x $bounds->{min_x}..$bounds->{max_x}, y $bounds->{min_y}..$bounds->{max_y}\n"; + } + + print "Cells only present on left map: $comparison->{left_extra}\n" if $comparison->{left_extra}; + print "Cells only present on right map: $comparison->{right_extra}\n" if $comparison->{right_extra}; + + if (%{ $comparison->{transition_counts} }) { + print "\nTransition summary:\n"; + foreach my $transition (sort { + $comparison->{transition_counts}{$b} <=> $comparison->{transition_counts}{$a} || $a cmp $b + } keys %{ $comparison->{transition_counts} }) { + my ($left_value, $right_value) = split /->/, $transition, 2; + printf " %3d %-23s -> %3d %-23s : %d cells\n", + $left_value, + describe_tile($left_value), + $right_value, + describe_tile($right_value), + $comparison->{transition_counts}{$transition}; + } + } + + if (%{ $comparison->{row_counts} }) { + print "\nRows with changes:\n"; + foreach my $y (sort { $a <=> $b } keys %{ $comparison->{row_counts} }) { + print " y=$y : $comparison->{row_counts}{$y} changed cells\n"; + } + } + + print "\nChanged cells"; + print " (showing up to $limit)" if $limit; + print ":\n"; + + my $shown = 0; + foreach my $change (@{$changes}) { + last if $limit && $shown >= $limit; + printf " (%d, %d): %3d %-23s -> %3d %-23s\n", + $change->{x}, + $change->{y}, + $change->{left}, + describe_tile($change->{left}), + $change->{right}, + describe_tile($change->{right}); + $shown++; + } + + if ($limit && $change_count > $limit) { + print " ... " . ($change_count - $limit) . " more changed cells omitted\n"; + } +} + +sub describe_tile { + my ($value) = @_; + my %known = ( + TILE_NOWALK() => 'nowalk', + TILE_WALK() => 'walk', + TILE_WATER() => 'water', + TILE_WALK() | TILE_WATER() => 'walk|water', + TILE_WATER() | TILE_SNIPE() => 'water|snipe', + TILE_CLIFF() => 'cliff', + TILE_CLIFF() | TILE_SNIPE() => 'cliff|snipe', + ); + + return $known{$value} if exists $known{$value}; + + my @parts; + push @parts, 'walk' if ($value & TILE_WALK) == TILE_WALK; + push @parts, 'snipe' if ($value & TILE_SNIPE) == TILE_SNIPE; + push @parts, 'water' if ($value & TILE_WATER) == TILE_WATER; + push @parts, 'cliff' if ($value & TILE_CLIFF) == TILE_CLIFF; + + return @parts ? join('|', @parts) : 'none'; +} + +sub min { + return $_[0] < $_[1] ? $_[0] : $_[1]; +} diff --git a/plugins/NewAStarAvoid/NewAStarAvoid.pl b/plugins/NewAStarAvoid/NewAStarAvoid.pl deleted file mode 100644 index ab8a25b47c..0000000000 --- a/plugins/NewAStarAvoid/NewAStarAvoid.pl +++ /dev/null @@ -1,786 +0,0 @@ -package NewAStarAvoid; - -use strict; -use Globals; -use Settings; -use Misc; -use Plugins; -use Utils; -use Log qw(message debug error warning); -use Data::Dumper; -$Data::Dumper::Sortkeys = 1; - -Plugins::register('NewAStarAvoid', 'Enables smart pathing using the dynamic aspect of A* Lite pathfinding', \&onUnload); - -use constant { - PLUGIN_NAME => 'NewAStarAvoid', - ENABLE_MOVE => 0, - ENABLE_REMOVE => 1, -}; - -use constant { - ENABLE_AVOID_MONSTERS => 1, - ENABLE_AVOID_PLAYERS => 0, - ENABLE_AVOID_AREASPELLS => 1, - ENABLE_AVOID_PORTALS => 1, -}; - -my $hooks = Plugins::addHooks( - ['PathFindingReset', \&on_PathFindingReset], # Changes args - ['AI_pre/manual', \&on_AI_pre_manual], # Recalls routing - ['packet_mapChange', \&on_packet_mapChange], - ['undefined_object_id', \&use_od], -); - -my $obstacle_hooks = Plugins::addHooks( - # Mobs - ['add_monster_list', \&on_add_monster_list], - ['monster_disappeared', \&on_monster_disappeared], - ['monster_moved', \&on_monster_moved], - - # Players - ['add_player_list', \&on_add_player_list], - ['player_disappeared', \&on_player_disappeared], - ['player_moved', \&on_player_moved], - - # Spells - ['packet_areaSpell', \&on_add_areaSpell_list], - ['packet_pre/area_spell_disappears', \&on_areaSpell_disappeared], - - # portals - ['add_portal_list', \&on_add_portal_list], - ['portal_disappeared', \&on_portal_disappeared], - - ['actor_avoid_removal', \&on_actor_avoid_removal], -); - -my $mobhooks = Plugins::addHooks( - ['AI::Attack::process', \&on_getBestTarget, undef], - ['getBestTarget', \&on_getBestTarget], -); - -my $chooks = Commands::register( - ['od', 'obstacles dump', \&use_od], -); - -sub onUnload { - Plugins::delHooks($hooks); - Plugins::delHooks($obstacle_hooks); - Plugins::delHooks($mobhooks); - Commands::unregister($chooks); -} - -my %mob_nameID_obstacles = ( - # Geographer - 1368 => { - weight => 2000, - dist => 12, - drop_target_near => 0, - drop_dest_near => 1, - }, - - # Muscipular - 1780 => { - weight => 2000, - dist => 12, - drop_target_near => 0, - drop_dest_near => 1, - }, - - # Drosera - 1781 => { - weight => 2000, - dist => 12, - drop_target_near => 0, - drop_dest_near => 1, - }, -); - -my %player_name_obstacles = ( - -); - -my %area_spell_type_obstacles = ( - 135 => { - weight => 2000, - dist => 12, - drop_target_near => 0, - drop_dest_near => 1, - }, - 136 => { - weight => 2000, - dist => 12, - drop_target_near => 0, - drop_dest_near => 1, - }, -); - -my %portals_obstacles = ( - weight => 5000, - dist => 12, -); - -my %obstaclesList; - -my %removed_obstacle_still_in_list; - -my $mustRePath = 0; - -my $weight_limit = 65000; - -sub use_od { - warning "[NewAStarAvoid] [use_od] obstaclesList Dump: " . Dumper(\%obstaclesList); - warning "[NewAStarAvoid] [use_od] removed_obstacle_still_in_list Dump: " . Dumper(\%removed_obstacle_still_in_list); -} - -sub on_packet_mapChange { - undef %obstaclesList; - undef %removed_obstacle_still_in_list; - $mustRePath = 0; -} - -sub on_getBestTarget { - my ($hook, $args) = @_; - - my $target = $args->{target}; - my $targetPos = calcPosFromPathfinding($field, $target); - - my $is_dropped = isTargetDroppedObstacle($target); - - my $drop_string; - if ($hook eq 'AI::Attack::process') { - $drop_string = 'Dropping'; - } elsif ($hook eq 'getBestTarget') { - $drop_string = 'Not picking'; - } - - my $obstacle = is_there_an_obstacle_near_pos($targetPos, 1); - if (defined $obstacle) { - warning "[NewAStarAvoid] [$hook] $drop_string target ".$args->{target}." because there is an Obstacle nearby.\n" if (!$is_dropped);; - if ($hook eq 'AI::Attack::process') { - AI::dequeue() while (AI::inQueue("attack")) - } - $target->{attackFailedObstacle} = 1; - $args->{return} = 1; - return; - } - - if ($is_dropped) { - # Release mobs that are no longer near obstacles, we can do this to any mobs because we keep a list of distant obstacles saved - warning "[NewAStarAvoid] [$hook] Releasing target $target from block, it no longer meets blocking criteria.\n"; - $target->{attackFailedObstacle} = 0; - } -} - -sub isTargetDroppedObstacle { - my ($target) = @_; - return 1 if (exists $target->{attackFailedObstacle} && $target->{attackFailedObstacle} == 1); - return 0; -} - -# 1 => target -# 2 => dest -sub is_there_an_obstacle_near_pos { - #warning "[".PLUGIN_NAME."] [is_there_an_obstacle_near_pos]\n"; - my ($pos, $type) = @_; - foreach my $obstacle_ID (keys %obstaclesList) { - my $obstacle = $obstaclesList{$obstacle_ID}; - - if (($type == 1 && $obstacle->{drop_target_near} == 1) || ($type == 2 && $obstacle->{drop_dest_near} == 1)) { - my $obstacle_last_pos = $obstacle->{pos_to}; - - my $dist = blockDistance($pos, $obstacle_last_pos); - my $min_dist = 13;#TODO config this - next unless ($dist <= $min_dist); - - return 1; - } - } - return undef; -} - -sub on_AI_pre_manual_adjust_route_step_near_obstacle { - return unless (scalar keys(%obstaclesList)); - - my $pos = calcPosFromPathfinding($field, $char); - - my $min_found; - - foreach my $obstacle_ID (keys %obstaclesList) { - my $obstacle = $obstaclesList{$obstacle_ID}; - #next unless ($obstacle->{type} eq 'portal'); - - my $dist = blockDistance($pos, $obstacle->{pos_to}); - if (!defined $min_found || $min_found > $dist) { - $min_found = $dist; - } - } - - if ($min_found > 10) { - check_and_change_config_if_necessary('route_step', 13); - - } elsif ($min_found <= 3) { - check_and_change_config_if_necessary('route_step', 5); - - } else { - check_and_change_config_if_necessary('route_step', $min_found); - } - -} - -sub check_and_change_config_if_necessary { - my ($key, $value) = @_; - if ($config{$key} ne $value) { - Misc::configModify($key, $value, 1); - } -} - -sub on_AI_pre_manual_drop_route_dest_near_Obstacle { - #warning "[".PLUGIN_NAME."] [on_AI_pre_manual_drop_route_dest_near_Obstacle]\n"; - my @obstacles = keys(%obstaclesList); - return unless (@obstacles > 0); - - my $arg_i; - if (AI::is("route")) { - $arg_i = 0; - return if (AI::action(1) eq "attack"); - } elsif (AI::action() eq "move" && AI::action(1) eq "route") { - $arg_i = 1; - return if (AI::action(2) eq "attack"); - } else { - return; - } - - - my $args = AI::args($arg_i); - my $task = get_task($args); - return unless (defined $task); - - return unless ($task->{isRandomWalk} || ($task->{isToLockMap} && $field->baseName eq $config{'lockMap'})); - - my $obstacle = is_there_an_obstacle_near_pos($task->{dest}{pos}, 2); - if (defined $obstacle) { - warning "[avoidObstacle 5] Dropping current route dest because an Obstacle appeared near it.\n"; - AI::clear("move", "route"); - } -} - -################################################### -######## Main obstacle management -################################################### - -sub add_obstacle { - my ($actor, $obstacle, $type) = @_; - #warning "[".PLUGIN_NAME."] [add_obstacle]\n"; - - if (exists $removed_obstacle_still_in_list{$actor->{ID}}) { - debug "[".PLUGIN_NAME."] New obstacle $actor on location ".$actor->{pos_to}{x}." ".$actor->{pos_to}{y}." already exists in removed_obstacle_still_in_list, deleting from it and updating position.\n"; - delete $obstaclesList{$actor->{ID}}; - delete $removed_obstacle_still_in_list{$actor->{ID}}; - } - - debug "[".PLUGIN_NAME."] Adding obstacle $actor on location ".$actor->{pos_to}{x}." ".$actor->{pos_to}{y}.".\n"; - - my $weight_changes = create_changes_array($actor->{pos_to}, $obstacle); - - $obstaclesList{$actor->{ID}}{pos_to} = $actor->{pos_to}; - $obstaclesList{$actor->{ID}}{weight} = $weight_changes; - $obstaclesList{$actor->{ID}}{type} = $type; - if ($type eq 'spell') { - $obstaclesList{$actor->{ID}}{name} = $actor->{'type'}; - } else { - $obstaclesList{$actor->{ID}}{name} = $actor->name; - } - if ($type eq 'monster') { - $obstaclesList{$actor->{ID}}{nameID} = $actor->{nameID}; - } - - define_extras($actor->{ID}, $obstacle); - - $mustRePath = 1; -} - -sub define_extras { - #warning "[".PLUGIN_NAME."] [define_extras]\n"; - my ($ID, $obstacle) = @_; - - if (exists $obstacle->{drop_target_near} && defined $obstacle->{drop_target_near} && $obstacle->{drop_target_near} == 1) { - $obstaclesList{$ID}{drop_target_near} = 1; - } else { - $obstaclesList{$ID}{drop_target_near} = 0; - } - - if (exists $obstacle->{drop_dest_near} && defined $obstacle->{drop_dest_near} && $obstacle->{drop_dest_near} == 1) { - $obstaclesList{$ID}{drop_dest_near} = 1; - } else { - $obstaclesList{$ID}{drop_dest_near} = 0; - } -} - -sub move_obstacle { - #warning "[".PLUGIN_NAME."] [move_obstacle]\n"; - my ($actor, $obstacle, $type) = @_; - - return unless (ENABLE_MOVE); - - debug "[".PLUGIN_NAME."] Moving obstacle $actor (from ".$actor->{pos}{x}." ".$actor->{pos}{y}." to ".$actor->{pos_to}{x}." ".$actor->{pos_to}{y}.").\n"; - - my $weight_changes = create_changes_array($actor->{pos_to}, $obstacle); - - $obstaclesList{$actor->{ID}}{pos_to} = $actor->{pos_to}; - $obstaclesList{$actor->{ID}}{weight} = $weight_changes; - - $mustRePath = 1; -} - -sub remove_obstacle { - #warning "[".PLUGIN_NAME."] [remove_obstacle]\n"; - my ($actor, $type, $reason) = @_; - - return unless (ENABLE_REMOVE); - return if ($type eq 'portal'); - - if (($type eq 'monster' || $type eq 'player') && $reason eq 'outofsight') { - $removed_obstacle_still_in_list{$actor->{ID}} = 1; - debug "[".PLUGIN_NAME."] Putting obstacle $actor from ".$actor->{pos_to}{x}." ".$actor->{pos_to}{y}." in to the removed_obstacle_still_in_list.\n"; - - } else { - debug "[".PLUGIN_NAME."] Removing obstacle $actor from ".$actor->{pos_to}{x}." ".$actor->{pos_to}{y}.".\n"; - delete $obstaclesList{$actor->{ID}}; - $mustRePath = 1; - } -} - -################################################### -######## Tecnical subs -################################################### - -sub on_AI_pre_manual { - on_AI_pre_manual_adjust_route_step_near_obstacle(); - on_AI_pre_manual_drop_route_dest_near_Obstacle(); - on_AI_pre_manual_removed_obstacle_still_in_list(); - on_AI_pre_manual_repath(); -} - -sub on_AI_pre_manual_removed_obstacle_still_in_list { - #warning "[".PLUGIN_NAME."] [on_AI_pre_manual_removed_obstacle_still_in_list]\n"; - my @obstacles = keys(%removed_obstacle_still_in_list); - return unless (@obstacles > 0); - - my $sight = ($config{clientSight}-3); # 3 cell leeway? - - #warning "[".PLUGIN_NAME."] removed_obstacle_still_in_list: ".(scalar @obstacles)."\n"; - - my $realMyPos = calcPosition($char); - - OBSTACLE: foreach my $obstacle_ID (@obstacles) { - my $obstacle = $obstaclesList{$obstacle_ID}; - - my $dist = blockDistance($realMyPos, $obstacle->{pos_to}); - - next OBSTACLE unless ($dist < $sight); - - my $target = findObstacleObjectUsingID($obstacle, $obstacle_ID); - - # Should never happen - if ($target) { - warning "[REMOVING TEST] wwwwttttffffff 1.\n"; - } else { - debug "[removed_obstacle_still_in_list] Removing obstacle ".$obstacle->{name}." (".$obstacle->{type}.") from ".$obstacle->{pos_to}{x}." ".$obstacle->{pos_to}{y}." we at ($realMyPos->{x} $realMyPos->{y}) dist:$dist, sight:$sight.\n"; - delete $obstaclesList{$obstacle_ID}; - delete $removed_obstacle_still_in_list{$obstacle_ID}; - $mustRePath = 1; - } - } -} - -sub findObstacleObjectUsingID { - my ($obstacle, $obstacle_ID) = @_; - - #LIST: foreach my $list ($playersList, $monstersList, $npcsList, $petsList, $portalsList, $slavesList, $elementalsList) { - - if ($obstacle->{type} eq 'monster') { - my $actor = $monstersList->getByID($obstacle_ID); - return $actor if ($actor); - - } elsif ($obstacle->{type} eq 'player') { - my $actor = $playersList->getByID($obstacle_ID); - return $actor if ($actor); - } - - return undef; -} - -sub on_AI_pre_manual_repath { - #warning "[".PLUGIN_NAME."] [on_AI_pre_manual_repath]\n"; - return unless ($mustRePath); - - my $arg_i; - my $arg_i2; - - if (AI::is("route")) { - $arg_i = 0; - if (AI::action(1) eq "attack") { - if (AI::action(2) eq "route") { - $arg_i2 = 2; - } elsif (AI::action(3) eq "route") { - $arg_i2 = 3; - } - } - } elsif (AI::is("move") && AI::action(1) eq "route") { - $arg_i = 1; - if (AI::action(2) eq "attack") { - if (AI::action(3) eq "route") { - $arg_i2 = 3; - } elsif (AI::action(4) eq "route") { - $arg_i2 = 4; - } - } - } else { - return; - } - - $mustRePath = 0; - - my $args = AI::args($arg_i); - my $task = get_task($args); - if (defined $task) { - if (scalar @{$task->{solution}} == 0) { - Log::debug "[NewAStarAvoid] [on_AI_pre_manual_repath] Route already reseted.\n"; - } else { - Log::debug "[NewAStarAvoid] [on_AI_pre_manual_repath] Reseting route.\n"; - $task->resetRoute; - } - } - - return unless (defined $arg_i2); - - my $args2 = AI::args($arg_i2); - my $task2 = get_task($args2); - if (defined $task2) { - if (scalar @{$task2->{solution}} == 0) { - Log::debug "[NewAStarAvoid] [on_AI_pre_manual_repath] [args2] Route second already reseted.\n"; - } else { - Log::debug "[NewAStarAvoid] [on_AI_pre_manual_repath] [args2] Reseting second route.\n"; - $task2->resetRoute; - } - } -} - -sub get_task { - #warning "[".PLUGIN_NAME."] [get_task]\n"; - my ($args) = @_; - if (UNIVERSAL::isa($args, 'Task::Route')) { - return $args; - } elsif (UNIVERSAL::isa($args, 'Task::MapRoute') && $args->getSubtask && UNIVERSAL::isa($args->getSubtask, 'Task::Route')) { - return $args->getSubtask; - } else { - return undef; - } -} - -sub on_PathFindingReset { - #warning "[".PLUGIN_NAME."] [on_PathFindingReset]\n"; - my (undef, $hookargs) = @_; - - return unless (exists $hookargs->{args}{getRoute} && $hookargs->{args}{getRoute} == 1); - - my @obstacles = keys(%obstaclesList); - - #warning "[".PLUGIN_NAME."] on_PathFindingReset before check, there are ".@obstacles." obstacles.\n"; - - return unless (@obstacles > 0); - - my $args = $hookargs->{args}; - - return if ($args->{field}->name ne $field->name); - - #Log::warning "[test] on_PathFindingReset: Using grided info for ".@obstacles." obstacles.\n"; - - $args->{customWeights} = 1; - $args->{secondWeightMap} = get_final_grid(); - - $args->{avoidWalls} = 1 unless (defined $args->{avoidWalls}); - $args->{weight_map} = \($args->{field}->{weightMap}) unless (defined $args->{weight_map}); - - $args->{randomFactor} = 0 unless (defined $args->{randomFactor}); - $args->{useManhattan} = 0 unless (defined $args->{useManhattan}); - - $args->{timeout} = 1500 unless ($args->{timeout}); - $args->{width} = $args->{field}{width} unless ($args->{width}); - $args->{height} = $args->{field}{height} unless ($args->{height}); - $args->{min_x} = 0 unless (defined $args->{min_x}); - $args->{max_x} = ($args->{width}-1) unless (defined $args->{max_x}); - $args->{min_y} = 0 unless (defined $args->{min_y}); - $args->{max_y} = ($args->{height}-1) unless (defined $args->{max_y}); - - $hookargs->{return} = 0; - - #warning "DUMP on_PathFindingReset - ".Dumper($args); - - #warning "[".PLUGIN_NAME."] [end on_PathFindingReset]\n"; -} - -sub getOffset { - my ($x, $width, $y) = @_; - return (($y * $width) + $x); -} - -sub get_final_grid { - my $changes = sum_all_changes(); - return $changes; -} - -sub get_weight_for_block { - my ($ratio, $dist) = @_; - if ($dist == 0) { - $dist = 1; - } - my $weight = int($ratio/($dist*$dist)); - $weight = assertWeightBellowLimit($weight, $weight_limit); - return $weight; -} - -sub assertWeightBellowLimit { - my ($weight, $weight_limit) = @_; - if ($weight >= $weight_limit) { - $weight = $weight_limit; - } - return $weight; -} - -sub create_changes_array { - #warning "[".PLUGIN_NAME."] [create_changes_array]\n"; - my ($obstacle_pos, $obstacle) = @_; - - my %obstacle = %{$obstacle}; - - my $max_distance = $obstacle{dist}; - my $ratio = $obstacle{weight}; - - my @changes_array; - - my ($min_x, $min_y, $max_x, $max_y) = $field->getSquareEdgesFromCoord($obstacle_pos, $max_distance); - - my @y_range = ($min_y..$max_y); - my @x_range = ($min_x..$max_x); - - foreach my $y (@y_range) { - foreach my $x (@x_range) { - next unless ($field->isWalkable($x, $y)); - my $pos = { - x => $x, - y => $y - }; - - my $distance = adjustedBlockDistance($pos, $obstacle_pos); - my $delta_weight = get_weight_for_block($ratio, $distance); - #warning "[".PLUGIN_NAME."] $x $y ($distance) -> $delta_weight.\n"; - push(@changes_array, { - x => $x, - y => $y, - weight => $delta_weight - }); - } - } - - @changes_array = sort { $b->{weight} <=> $a->{weight} } @changes_array; - - return \@changes_array; -} - -sub sum_all_changes { - my %changes_hash; - #warning "[".PLUGIN_NAME."] [sum_all_changes]\n"; - - #warning "[".PLUGIN_NAME."] 1 obstaclesList: ". Data::Dumper::Dumper \%obstaclesList; - - foreach my $key (keys %obstaclesList) { - #warning "[".PLUGIN_NAME."] sum_all_avoid - testing obstacle at $obstaclesList{$key}{pos_to}{x} $obstaclesList{$key}{pos_to}{y}.\n"; - foreach my $change (@{$obstaclesList{$key}{weight}}) { - my $x = $change->{x}; - my $y = $change->{y}; - my $changed = $change->{weight}; - $changes_hash{$x}{$y} += $changed; - } - } - - my @rebuilt_array; - foreach my $x_keys (keys %changes_hash) { - foreach my $y_keys (keys %{$changes_hash{$x_keys}}) { - next if ($changes_hash{$x_keys}{$y_keys} == 0); - my $weight = assertWeightBellowLimit($changes_hash{$x_keys}{$y_keys}, $weight_limit); - push(@rebuilt_array, { x => $x_keys, y => $y_keys, weight => $weight }); - } - } - - #warning "[".PLUGIN_NAME."] 2 rebuilt: ". Data::Dumper::Dumper \@rebuilt_array; - - return \@rebuilt_array; -} - -################################################### -######## Player avoiding -################################################### - -sub on_add_player_list { - return unless (ENABLE_AVOID_PLAYERS); - my (undef, $args) = @_; - my $actor = $args; - - return unless (exists $player_name_obstacles{$actor->{name}}); - - my %obstacle = %{$player_name_obstacles{$actor->{name}}}; - - add_obstacle($actor, \%obstacle, 'player'); -} - -sub on_player_moved { - return unless (ENABLE_AVOID_PLAYERS); - my (undef, $args) = @_; - my $actor = $args; - - return unless (exists $obstaclesList{$actor->{ID}}); - - my %obstacle = %{$player_name_obstacles{$actor->{name}}}; - - move_obstacle($actor, \%obstacle, 'player'); -} - -sub on_player_disappeared { - return unless (ENABLE_AVOID_PLAYERS); - my (undef, $args) = @_; - my $actor = $args->{player}; - - return unless (exists $obstaclesList{$actor->{ID}}); - - remove_obstacle($actor, 'player'); -} - -################################################### -######## Mob avoiding -################################################### - -sub on_add_monster_list { - return unless (ENABLE_AVOID_MONSTERS); - my (undef, $args) = @_; - my $actor = $args; - - return unless (exists $mob_nameID_obstacles{$actor->{nameID}}); - - my %obstacle = %{$mob_nameID_obstacles{$actor->{nameID}}}; - - add_obstacle($actor, \%obstacle, 'monster'); -} - -sub on_monster_moved { - return unless (ENABLE_AVOID_MONSTERS); - my (undef, $args) = @_; - my $actor = $args; - - return unless (exists $obstaclesList{$actor->{ID}}); - - my %obstacle = %{$mob_nameID_obstacles{$actor->{nameID}}}; - - move_obstacle($actor, \%obstacle, 'monster'); -} - -sub on_monster_disappeared { - return unless (ENABLE_AVOID_MONSTERS); - my (undef, $args) = @_; - my $actor = $args->{monster}; - - return unless (exists $obstaclesList{$actor->{ID}}); - - my $reason; - if ($args->{type} == 0) { - $reason = 'outofsight'; - } else { - $reason = 'gone'; - } - - debug ("[on_monster_disappeared] $actor type $args->{type} | reason $reason\n", "route"); - remove_obstacle($actor, 'monster', $reason); -} - -################################################### -######## Spell avoiding -################################################### - -# TODO: Add fail flag check - -sub on_add_areaSpell_list { - return unless (ENABLE_AVOID_AREASPELLS); - my (undef, $args) = @_; - my $ID = $args->{ID}; - my $spell = $spells{$ID}; - - return unless (exists $area_spell_type_obstacles{$spell->{type}}); - - my %obstacle = %{$area_spell_type_obstacles{$spell->{type}}}; - - add_obstacle($spell, \%obstacle, 'spell'); -} - -sub on_areaSpell_disappeared { - return unless (ENABLE_AVOID_AREASPELLS); - my (undef, $args) = @_; - my $ID = $args->{ID}; - my $spell = $spells{$ID}; - - return unless (exists $obstaclesList{$spell->{ID}}); - - remove_obstacle($spell, 'spell'); -} - -################################################### -######## portals avoiding -################################################### - -sub on_add_portal_list { - return unless (ENABLE_AVOID_PORTALS); - my (undef, $args) = @_; - my $actor = $args; - - add_obstacle($actor, \%portals_obstacles, 'portal'); -} - -sub on_portal_disappeared { - return unless (ENABLE_AVOID_PORTALS); - my (undef, $args) = @_; - my $actor = $args->{portal}; - - #remove_obstacle($actor, 'portal'); -} - -################################################### -######## portals avoiding -################################################### - -sub on_actor_avoid_removal { - my (undef, $args) = @_; - my $actor = $args->{actor}; - - return unless (exists $obstaclesList{$actor->{ID}}); - - my $reason = 'outofsight'; - my $type; - - if ($actor->isa('Actor::Player')) { - $type = 'player'; - - } elsif ($actor->isa('Actor::Monster')) { - $type = 'monster'; - - } elsif ($actor->isa('Actor::Portal')) { - return; - - } else { - return; - } - - debug ("[NewAStarAvoid] [on_actor_avoid_removal] $actor type $args->{type}\n", "route"); - remove_obstacle($actor, $type, $reason); -} - -return 1; \ No newline at end of file diff --git a/plugins/avoidObstacles/avoidObstacles.pl b/plugins/avoidObstacles/avoidObstacles.pl new file mode 100644 index 0000000000..b163c86d59 --- /dev/null +++ b/plugins/avoidObstacles/avoidObstacles.pl @@ -0,0 +1,2328 @@ +######################################################################### +# avoidObstacles plugin for OpenKore +# +# Author: Henrybk +# +# Config-driven dynamic obstacle avoidance for routing and target +# selection using per-distance penalty and danger profiles. +# Configure behavior in control/config.txt. +######################################################################### +=pod +######## avoidObstacles ######## + +avoidObstacles_enable_move 0 +avoidObstacles_enable_remove 1 +avoidObstacles_enable_avoid_portals 1 +avoidObstacles_adjust_route_step 1 +avoidObstacles_weight_limit 65000 + +avoidObstaclesMonster 1368 { + enabled 1 + penalty_dist 2000, 2000, 500, 222, 125, 80, 55, 40, 31, 24, 20, 16, 13 + danger_dist 1, 1 + drop_destination_when_near_dist 13 +} + +avoidObstaclesMonster 1780 { + enabled 1 + penalty_dist 2000, 2000, 500, 222, 125, 80, 55, 40, 31, 24, 20, 16, 13 + danger_dist 1, 1 + drop_destination_when_near_dist 13 +} + +avoidObstaclesMonster 1781 { + enabled 1 + penalty_dist 2000, 2000, 500, 222, 125, 80, 55, 40, 31, 24, 20, 16, 13 + danger_dist 1, 1 + drop_destination_when_near_dist 13 +} + +avoidObstaclesSpell 135 { + enabled 1 + penalty_dist 2000, 2000, 500, 222, 125, 80, 55, 40, 31, 24, 20, 16, 13 + danger_dist 1, 1 + drop_destination_when_near_dist 13 +} + +avoidObstaclesSpell 136 { + enabled 1 + penalty_dist 2000, 2000, 500, 222, 125, 80, 55, 40, 31, 24, 20, 16, 13 + danger_dist 1, 1 + drop_destination_when_near_dist 13 +} + +avoidObstaclesDefaultPortals { + enabled 1 + penalty_dist 10000, 10000, 2500, 1111, 625, 400, 277, 204, 156, 123, 100, 82, 69 + danger_dist 1, 1, 1, 1, 1 + prohibited_dist 2 + drop_target_when_near_dist 13 + drop_destination_when_near_dist 13 +} + +avoidObstaclesCellsInMap job_hunte { + enabled 1 + cells 52 140, 53 140 + penalty_dist 500, 500, 125 + danger_dist 1, 1, 1 + prohibited_dist 1 + drop_target_when_near_dist 13 + drop_destination_when_near_dist 13 +} +=cut + +package avoidObstacles; + +use strict; +use AI; +use Globals; +use Misc; +use Plugins; +use Utils; +use Log qw(error message debug warning); +use Data::Dumper; +use Scalar::Util qw(refaddr); + +$Data::Dumper::Sortkeys = 1; + +use constant { + PLUGIN_NAME => 'avoidObstacles', +}; + +Plugins::register(PLUGIN_NAME, 'Enables smart pathing using config-driven dynamic obstacles', \&onUnload); + +my $hooks = Plugins::addHooks( + ['pos_load_config.txt', \&on_config_file_loaded, undef], + ['post_configModify', \&on_post_config_modify, undef], + ['post_bulkConfigModify', \&on_post_bulk_config_modify, undef], + ['getRoute', \&on_getRoute, undef], + ['route_step', \&on_route_step, undef], + ['AI_pre/manual', \&on_AI_pre_manual, undef], + ['add_prohibitedCells', \&on_add_prohibited_cells, undef], + ['add_dropDestinationCells', \&on_add_drop_destination_cells, undef], + ['packet_mapChange', \&on_packet_mapChange, undef], + ['undefined_object_id', \&use_dump, undef], +); + +my $obstacle_hooks = Plugins::addHooks( + ['add_monster_list', \&on_add_monster_list, undef], + ['monster_disappeared', \&on_monster_disappeared, undef], + ['monster_moved', \&on_monster_moved, undef], + ['add_player_list', \&on_add_player_list, undef], + ['player_disappeared', \&on_player_disappeared, undef], + ['player_moved', \&on_player_moved, undef], + ['packet_areaSpell', \&on_add_areaSpell_list, undef], + ['packet_pre/area_spell_disappears', \&on_areaSpell_disappeared, undef], + ['add_portal_list', \&on_add_portal_list, undef], + ['portal_disappeared', \&on_portal_disappeared, undef], + ['actor_avoid_removal', \&on_actor_avoid_removal, undef], +); + +my $mobhooks = Plugins::addHooks( + ['shouldDropTarget', \&on_shouldDropTarget, undef], + ['getBestTarget', \&on_getBestTarget, undef], +); + +my $pathfinding_weight_map_override; +my $cached_weight_map_field_name; +my $cached_weight_map_with_prohibited; +my $cached_final_grid = []; +my %cached_final_grid_index; + +my $chooks = Commands::register( + ['avoid', 'avoidObstacles controls: od [dump|reload|status]', \&command_avoid], +); + +my %plugin_settings; +my %mob_nameID_obstacles; +my %player_name_obstacles; +my %area_spell_type_obstacles; +my %cells_in_map_obstacles; +my %default_portal_obstacle; + +my %obstaclesList; +my %removed_obstacle_still_in_list; +my %cached_prohibited_distance_counts; +my %cached_prohibited_cells; +my %cached_prohibited_cell_counts; +my %cached_danger_cells; +my %cached_weight_map; + +my $mustRePath = 0; + +## LIMITATIONS: +# 1 - Will drop a randomwalk if there is an aobstacle near the destination, but not if the route to the destination crosses one +# TODO: This functionality can be added by defining a max danger accepted by randomwalk route and then asserting the randomwalk solution +# Eg: avoidObstacles_maxRandomWalkDanger 10 - if the summed up danger of all cells in solution is greater than 10, drop this route +# 2 - Chars with ranged attacks can have issues when you are in a valid spot, target is in a valid spot and not moving to a bad spot but between you and the target there is an obstacle +# because the target might start walking to you and cross a prohibited area, which will make Attack.pm drop the target +# Could be averted by running a get_solution or checklos between char and target and excluding prohibited spots +# 3 - Openkore has no knowlodge of the 'cost' we calculate here, it only knows route cell length, if we want to a diferent path because of the obstacles +# Eg: There is an obstacle blocking the bridge, we could route through another map or try teleporting to the other side instead of walking there +# Then we would need a way of sending this information to openkore and actually making the decision there + +## Purpose: Clears derived pathfinding caches that depend on field/base-map identity. +## Args: none. +## Returns: nothing. +## Notes: The incremental cell caches stay live and current. This helper only drops +## the canonical blocked weight-map cache that depends on the current field/base map. +sub invalidate_pathfinding_caches { + undef $cached_weight_map_field_name; + undef $cached_weight_map_with_prohibited; + undef $pathfinding_weight_map_override; +} + +## Purpose: Resets the incremental obstacle caches kept for the current map. +## Args: none. +## Returns: nothing. +## Notes: This wipes the summed per-cell caches and their supporting indexes so the +## plugin can rebuild the current field state from scratch safely. +sub reset_live_aggregate_state { + %cached_prohibited_distance_counts = (); + %cached_prohibited_cells = (); + %cached_prohibited_cell_counts = (); + %cached_danger_cells = (); + %cached_weight_map = (); + $cached_final_grid = []; + %cached_final_grid_index = (); + invalidate_pathfinding_caches(); +} + +## Purpose: Checks whether a local path crosses prohibited cells in an unsafe way. +## Args: `($solution, $prohibited_cells)`. +## Returns: `1` when the path must be rejected, otherwise `0`. +## Notes: A path is rejected if it enters a prohibited zone from outside, leaves and +## re-enters one, or moves deeper into the initial prohibited zone instead of escaping. +sub route_crosses_prohibited_cells { + my ($solution, $prohibited_cells) = @_; + + return 0 unless $solution && @{$solution}; + return 0 unless $prohibited_cells; + + my $started_inside; + my $left_initial_zone = 0; + my $previous_inside_distance; + foreach my $node (@{$solution}) { + next unless $node; + + my $cell_distance = $prohibited_cells->{$node->{x}} && $prohibited_cells->{$node->{x}}{$node->{y}}; + my $inside = defined $cell_distance; + + if (!defined $started_inside) { + $started_inside = $inside ? 1 : 0; + if ($inside) { + $previous_inside_distance = $cell_distance; + } else { + $left_initial_zone = 1; + } + next; + } + + if ($inside) { + return 1 if !$started_inside || $left_initial_zone; + return 1 if defined $previous_inside_distance && $cell_distance < $previous_inside_distance; + $previous_inside_distance = $cell_distance; + } else { + $left_initial_zone = 1 if $started_inside; + } + } + + return 0; +} + +## Purpose: Returns the built-in plugin settings used when config.txt has no overrides. +## Args: none. +## Returns: A flat key/value list suitable for assigning into `%plugin_settings`. +## Notes: This centralizes the default runtime policy so config reloads always start +## from a known baseline before user overrides are applied. +sub default_settings { + return ( + enable_move => 0, + enable_remove => 0, + enable_avoid_portals => 0, + adjust_route_step => 0, + weight_limit => 65000, + ); +} + +## Purpose: Restores every plugin configuration table to its built-in defaults. +## Args: none. +## Returns: nothing. +## Notes: Reload code calls this first so stale config values from a prior parse do +## not survive after options are removed or changed in `config.txt`. +sub reset_plugin_configuration { + %plugin_settings = default_settings(); + %mob_nameID_obstacles = (); + %player_name_obstacles = (); + %area_spell_type_obstacles = (); + %cells_in_map_obstacles = (); + %default_portal_obstacle = default_portal_obstacle_entry(); +} + +## Purpose: Converts a short internal setting name into the corresponding config key. +## Args: `($key)` where `$key` is the short setting name such as `enable_move`. +## Returns: The full `config.txt` key string, for example `avoidObstacles_enable_move`. +## Notes: This avoids hard-coding the plugin prefix in multiple config-parsing loops. +sub plugin_config_key { + my ($key) = @_; + return 'avoidObstacles_' . $key; +} + +## Purpose: Checks whether a config key belongs to this plugin. +## Args: `($key)` which may be a flat setting key or a block-scoped obstacle key. +## Returns: `1` when the key belongs to avoidObstacles, otherwise `0`. +## Notes: Runtime config hooks use this to avoid unnecessary full plugin reloads. +sub is_plugin_config_key { + my ($key) = @_; + + return 0 unless defined $key && $key ne ''; + return 1 if $key =~ /^avoidObstacles_/; + return 1 if $key =~ /^avoidObstacles(?:Monster|Player|Spell|CellsInMap)_/; + return 1 if $key =~ /^avoidObstaclesDefaultPortals(?:_|$)/; + + return 0; +} + +## Purpose: Detects whether a bulk config change touched any avoidObstacles key. +## Args: `($keys)` where `$keys` is the hashref provided by the bulk config hook. +## Returns: `1` if any key belongs to the plugin, otherwise `0`. +## Notes: This lets the plugin reload once at the end of a bulk update instead of +## reloading on every individual key change. +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: Loads the plugin's flat top-level settings from `config.txt`. +## Args: none. +## Returns: nothing. +## Notes: It only overwrites keys that are explicitly present and keeps built-in +## defaults for anything the user omitted. +sub load_settings_from_config { + foreach my $key (keys %plugin_settings) { + my $config_key = plugin_config_key($key); + next unless defined $config{$config_key} && $config{$config_key} ne ''; + + if ($key =~ /^enable_|^adjust_route_step$/) { + $plugin_settings{$key} = normalize_bool($config{$config_key}, 'config.txt', $config_key); + } else { + $plugin_settings{$key} = normalize_number($config{$config_key}, 'config.txt', $config_key); + } + } +} + +## Purpose: Loads repeated obstacle blocks from `config.txt` into one obstacle table. +## Args: `($prefix, $type, $target_hash)` describing the block prefix, identifier +## normalization type, and destination hashref. +## Returns: nothing. +## Notes: This is shared by monster, player, and spell obstacle blocks so their +## parsing stays consistent and uses the same normalization rules. +sub load_obstacle_blocks_from_config { + my ($prefix, $type, $target_hash) = @_; + + foreach my $block_key (sort keys %config) { + next unless $block_key =~ /^\Q$prefix\E_\d+$/; + + my $identifier_raw = $config{$block_key}; + next unless defined $identifier_raw && $identifier_raw ne ''; + + my $identifier = normalize_identifier($type, $identifier_raw); + my %entry = exists $target_hash->{$identifier} + ? %{ $target_hash->{$identifier} } + : default_obstacle_entry(); + + foreach my $option (qw(enabled penalty_dist danger_dist prohibited_dist drop_target_when_near_dist drop_destination_when_near_dist)) { + my $option_key = "${block_key}_${option}"; + next unless defined $config{$option_key} && $config{$option_key} ne ''; + + if ($option eq 'enabled') { + $entry{$option} = normalize_bool($config{$option_key}, 'config.txt', $option_key); + } else { + $entry{$option} = normalize_obstacle_number_or_profile($option, $config{$option_key}, 'config.txt', $option_key); + } + } + + $target_hash->{$identifier} = \%entry; + } +} + +## Purpose: Parses one comma-separated `x y` cell list from a CellsInMap config block. +## Args: `($cells_text, $map_name, $block_key)` from the current config entry. +## Returns: A list of `{ x => ..., y => ... }` hashes. +## Notes: Invalid or duplicate cells are skipped with warnings so a bad entry does +## not abort the rest of the block. +sub parse_cells_in_map_list { + my ($cells_text, $map_name, $block_key) = @_; + + my @cells; + my %seen_cells; + return @cells unless defined $cells_text && $cells_text ne ''; + + foreach my $cell_text (split /\s*,\s*/, $cells_text) { + next unless defined $cell_text && $cell_text ne ''; + + my ($x, $y) = $cell_text =~ /^\s*(\d+)\s+(\d+)\s*$/; + if (!defined $x || !defined $y) { + warning "[" . PLUGIN_NAME . "] Invalid cell '$cell_text' in block $block_key for map $map_name. Expected 'x y'.\n"; + next; + } + + my $cell_key = "$x,$y"; + next if $seen_cells{$cell_key}++; + push @cells, { x => 0 + $x, y => 0 + $y }; + } + + return @cells; +} + +## Purpose: Loads all configured static cell obstacle blocks from `config.txt`. +## Args: none. +## Returns: nothing. +## Notes: The result is grouped by map name so the plugin can rebuild only the +## current map's static cell obstacles when a field is entered or reloaded. +sub load_cells_in_map_obstacles_from_config { + foreach my $block_key (sort keys %config) { + next unless $block_key =~ /^avoidObstaclesCellsInMap_\d+$/; + + my $map_name = $config{$block_key}; + next unless defined $map_name && $map_name ne ''; + + my %entry = default_obstacle_entry(); + + foreach my $option (qw(enabled penalty_dist danger_dist prohibited_dist drop_target_when_near_dist drop_destination_when_near_dist)) { + my $option_key = "${block_key}_${option}"; + next unless defined $config{$option_key} && $config{$option_key} ne ''; + + if ($option eq 'enabled') { + $entry{$option} = normalize_bool($config{$option_key}, 'config.txt', $option_key); + } else { + $entry{$option} = normalize_obstacle_number_or_profile($option, $config{$option_key}, 'config.txt', $option_key); + } + } + + my @cells = parse_cells_in_map_list($config{"${block_key}_cells"}, $map_name, $block_key); + next unless @cells; + + push @{ $cells_in_map_obstacles{$map_name} }, { + config => \%entry, + cells => \@cells, + }; + } +} + +## Purpose: Loads the default portal obstacle profile from `config.txt`. +## Args: none. +## Returns: nothing. +## Notes: Portals do not have per-portal custom blocks, so this reads one shared +## profile that is later cloned for every live portal obstacle. +sub load_default_portal_obstacle_from_config { + my %entry = default_portal_obstacle_entry(); + + foreach my $block_key (sort keys %config) { + next unless $block_key =~ /^avoidObstaclesDefaultPortals_\d+$/; + + foreach my $option (qw(enabled penalty_dist danger_dist prohibited_dist drop_target_when_near_dist drop_destination_when_near_dist)) { + my $option_key = "${block_key}_${option}"; + next unless defined $config{$option_key} && $config{$option_key} ne ''; + + if ($option eq 'enabled') { + $entry{$option} = normalize_bool($config{$option_key}, 'config.txt', $option_key); + } else { + $entry{$option} = normalize_obstacle_number_or_profile($option, $config{$option_key}, 'config.txt', $option_key); + } + } + } + + %default_portal_obstacle = %entry; +} + +## Purpose: Reloads plugin configuration and rebuilds runtime obstacle state from it. +## Args: none. +## Returns: nothing. +## Notes: This is the main configuration entry point. It resets defaults, parses +## config tables, and then rebuilds live obstacles from the currently visible world. +sub reload_plugin_configuration { + reset_plugin_configuration(); + load_settings_from_config(); + load_obstacle_blocks_from_config('avoidObstaclesMonster', 'monster', \%mob_nameID_obstacles); + load_obstacle_blocks_from_config('avoidObstaclesPlayer', 'player', \%player_name_obstacles); + load_obstacle_blocks_from_config('avoidObstaclesSpell', 'spell', \%area_spell_type_obstacles); + load_cells_in_map_obstacles_from_config(); + load_default_portal_obstacle_from_config(); + + rebuild_obstacles_from_world(); +} + +## Purpose: Reacts to the full config load hook. +## Args: Hook arguments are ignored. +## Returns: nothing. +## Notes: It exists as a tiny hook adapter that funnels the event into the shared +## reload routine used by every config-related entry point. +sub on_config_file_loaded { + reload_plugin_configuration(); +} + +## Purpose: Reacts to a single runtime config modification. +## Args: `(undef, $args)` from `post_configModify`. +## Returns: nothing. +## Notes: It ignores unrelated keys and bulk operations, and reloads the plugin only +## when an avoidObstacles key changed. +sub on_post_config_modify { + my (undef, $args) = @_; + + return unless $args && is_plugin_config_key($args->{key}); + return if $args->{bulk}; + reload_plugin_configuration(); +} + +## Purpose: Reacts once after a bulk runtime config update finishes. +## Args: `(undef, $args)` from `post_bulkConfigModify`. +## Returns: nothing. +## Notes: This prevents redundant reloads during bulk edits while still rebuilding +## once if any avoidObstacles key was part of the batch. +sub on_post_bulk_config_modify { + my (undef, $args) = @_; + + return unless $args && bulk_includes_plugin_config_keys($args->{keys}); + reload_plugin_configuration(); +} + +## Purpose: Unregisters plugin hooks and commands during unload. +## Args: none. +## Returns: nothing. +## Notes: This exists so disabling or reloading the plugin leaves no stale hooks +## registered in the OpenKore runtime. +sub onUnload { + Plugins::delHooks($hooks) if $hooks; + Plugins::delHooks($obstacle_hooks) if $obstacle_hooks; + Plugins::delHooks($mobhooks) if $mobhooks; + Commands::unregister($chooks) if $chooks; +} + +## Purpose: Normalizes obstacle identifiers before storing or looking them up. +## Args: `($type, $identifier)` where `$type` controls the normalization strategy. +## Returns: The normalized identifier string. +## Notes: Player names are lowercased so lookups are case-insensitive, while numeric +## or spell identifiers are preserved as-is. +sub normalize_identifier { + my ($type, $identifier) = @_; + + if ($type eq 'player') { + return lc $identifier; + } + + return $identifier; +} + +## Purpose: Returns the default structure for one obstacle configuration entry. +## Args: none. +## Returns: A flat key/value list for one obstacle profile. +## Notes: Shared defaults keep monster, player, spell, and cell obstacles aligned on +## the same option names and sentinel values. +sub default_obstacle_entry { + return ( + enabled => 1, + penalty_dist => -1, + danger_dist => -1, + prohibited_dist => -1, + drop_target_when_near_dist => -1, + drop_destination_when_near_dist => -1, + ); +} + +## Purpose: Returns the built-in portal obstacle configuration. +## Args: none. +## Returns: A flat key/value list describing the default portal profile. +## Notes: Portals use stronger defaults than regular obstacles because stepping onto +## them can teleport or otherwise disrupt routeing. +sub default_portal_obstacle_entry { + return ( + enabled => 1, + penalty_dist => build_default_penalty_profile(10000, 12), + danger_dist => build_uniform_profile(4, 1), + prohibited_dist => 2, + drop_target_when_near_dist => 2, + drop_destination_when_near_dist => 2, + ); +} + +## Purpose: Normalizes a config value into a boolean. +## Args: `($value, $line_no, $key)` for diagnostics and parsing. +## Returns: `1` or `0`. +## Notes: Invalid values warn and fall back to `0` so parsing can continue safely. +sub normalize_bool { + my ($value, $line_no, $key) = @_; + return 1 if defined $value && $value =~ /^(?:1|true|yes|on)$/i; + return 0 if defined $value && $value =~ /^(?:0|false|no|off)$/i; + warning "[" . PLUGIN_NAME . "] Invalid boolean '$value' for $key on line $line_no. Using 0.\n"; + return 0; +} + +## Purpose: Normalizes a config value into a numeric scalar. +## Args: `($value, $line_no, $key)` for diagnostics and parsing. +## Returns: The numeric value, or `0` when invalid. +## Notes: This helper keeps numeric validation and warning format consistent across +## all plugin config parsing paths. +sub normalize_number { + my ($value, $line_no, $key) = @_; + if (defined $value && $value =~ /^-?(?:\d+(?:\.\d*)?|\.\d+)$/) { + return 0 + $value; + } + warning "[" . PLUGIN_NAME . "] Invalid numeric value '$value' for $key on line $line_no. Using 0.\n"; + return 0; +} + +## Purpose: Parses either a scalar distance or a per-distance profile from config. +## Args: `($option, $value, $line_no, $key)` describing the option being parsed. +## Returns: A numeric scalar for single-value options, or an arrayref profile for +## `penalty_dist` and `danger_dist`. +## Notes: The plugin allows profile options to define one value per block distance, +## so this helper centralizes the split-and-validate logic. +sub normalize_obstacle_number_or_profile { + my ($option, $value, $line_no, $key) = @_; + + if ($option eq 'penalty_dist' || $option eq 'danger_dist') { + my @parts = split /\s*,\s*/, $value; + my @profile; + + for (my $i = 0; $i < @parts; $i++) { + if (!defined $parts[$i] || $parts[$i] eq '') { + warning "[" . PLUGIN_NAME . "] Invalid empty profile value for $key\[$i\] on line $line_no. Using 0.\n"; + push @profile, 0; + next; + } + push @profile, normalize_number($parts[$i], $line_no, "$key\[$i\]"); + } + + return \@profile; + } + + return normalize_number($value, $line_no, $key); +} + +## Purpose: Builds a fixed-size profile where every distance has the same value. +## Args: `($max_distance, $value)`. +## Returns: An arrayref profile indexed by block distance. +## Notes: This is mainly used for convenience defaults such as uniform danger zones. +sub build_uniform_profile { + my ($max_distance, $value) = @_; + my @profile = map { $value } (0 .. $max_distance); + return \@profile; +} + +## Purpose: Builds the default inverse-square-style penalty profile. +## Args: `($ratio, $max_distance)` controlling the strength and maximum radius. +## Returns: An arrayref penalty profile indexed by block distance. +## Notes: It delegates per-cell math to `get_weight_for_block` so default profiles +## use the same weight clamping logic as custom obstacle contributions. +sub build_default_penalty_profile { + my ($ratio, $max_distance) = @_; + my @profile = map { get_weight_for_block($ratio, $_) } (0 .. $max_distance); + return \@profile; +} + +## Purpose: Returns the farthest distance represented by a profile. +## Args: `($profile)` which should be an arrayref. +## Returns: The maximum valid distance index, or `undef` for invalid input. +## Notes: Many obstacle builders use this to derive the square bounds they must scan. +sub profile_max_distance { + my ($profile) = @_; + return undef unless $profile && ref($profile) eq 'ARRAY'; + return $#{$profile}; +} + +## Purpose: Reads the danger value for one distance from a danger profile. +## Args: `($profile, $distance)`. +## Returns: The configured value at that distance, or `0` when out of range. +## Notes: Returning zero for invalid distances makes downstream danger accumulation +## code simpler because callers can just sum the result. +sub danger_profile_value_at_distance { + my ($profile, $distance) = @_; + return 0 unless $profile && ref($profile) eq 'ARRAY'; + return 0 unless defined $distance && $distance >= 0; + return 0 if $distance > $#{$profile}; + return $profile->[$distance] || 0; +} + +## Purpose: Reads the penalty value for one distance from a penalty profile. +## Args: `($profile, $distance)`. +## Returns: The configured value at that distance, or `undef` when out of range. +## Notes: Unlike danger values, penalty callers need to distinguish "no configured +## value" from zero, so this helper preserves `undef`. +sub penalty_profile_value_at_distance { + my ($profile, $distance) = @_; + return undef unless $profile && ref($profile) eq 'ARRAY'; + return undef unless defined $distance && $distance >= 0; + return undef if $distance > $#{$profile}; + return $profile->[$distance]; +} + +## Purpose: Rebuilds the full live obstacle list from the currently visible world. +## Args: none. +## Returns: nothing. +## Notes: This is used after config reloads and map resets so every visible monster, +## player, spell, portal, and configured static cell obstacle is re-applied cleanly. +sub rebuild_obstacles_from_world { + my $had_obstacles = scalar keys %obstaclesList; + + %obstaclesList = (); + %removed_obstacle_still_in_list = (); + reset_live_aggregate_state(); + return unless $field; + + rebuild_static_cell_obstacles_for_current_map(); + + foreach my $monster (@{ $monstersList ? $monstersList->getItems : [] }) { + my $obstacle = get_monster_obstacle($monster); + add_obstacle($monster, $obstacle, 'monster') if $obstacle; + } + + foreach my $player (@{ $playersList ? $playersList->getItems : [] }) { + my $obstacle = get_player_obstacle($player); + add_obstacle($player, $obstacle, 'player') if $obstacle; + } + + foreach my $spell_id (@spellsID) { + next unless $spell_id && $spells{$spell_id}; + my $obstacle = get_spell_obstacle($spells{$spell_id}); + add_obstacle($spells{$spell_id}, $obstacle, 'spell') if $obstacle; + } + + foreach my $portal_id (@portalsID) { + next unless $portal_id && $portals{$portal_id}; + my $obstacle = get_portal_obstacle(); + add_obstacle($portals{$portal_id}, $obstacle, 'portal') if $obstacle; + } + + $mustRePath = 1 if $had_obstacles || scalar keys %obstaclesList; +} + +## Purpose: Adds all configured static cell obstacles for the current map. +## Args: none. +## Returns: nothing. +## Notes: Static cells are stored in config rather than discovered from actors, so +## they are rebuilt separately whenever the active field changes. +sub rebuild_static_cell_obstacles_for_current_map { + return unless $field; + + my $map_name = $field->baseName; + return unless defined $map_name && exists $cells_in_map_obstacles{$map_name}; + + my $block_index = 0; + foreach my $block (@{ $cells_in_map_obstacles{$map_name} }) { + next unless $block->{config} && $block->{config}{enabled}; + + my $cell_index = 0; + foreach my $pos (@{ $block->{cells} || [] }) { + add_static_cell_obstacle($map_name, $block_index, $cell_index, $pos, $block->{config}); + $cell_index++; + } + + $block_index++; + } +} + +## Purpose: Implements the `od` console command for this plugin. +## Args: `($cmd, $args)` from the command dispatcher. +## Returns: nothing. +## Notes: This command is intentionally small and operational: dump internals, +## reload config, or print a compact status summary. +sub command_avoid { + my ($cmd, $args) = @_; + $args ||= ''; + $args =~ s/^\s+|\s+$//g; + + if ($args eq '' || $args eq 'dump') { + use_dump(); + return; + } + + if ($args eq 'reload') { + Misc::parseReload('config\.txt'); + message "[" . PLUGIN_NAME . "] Reloaded settings from config.txt.\n", 'success'; + return; + } + + if ($args eq 'status') { + message sprintf( + "[%s] obstacles=%d removed-cache=%d monsters=%d players=%d spells=%d portals=%d config=config.txt\n", + PLUGIN_NAME, + scalar keys %obstaclesList, + scalar keys %removed_obstacle_still_in_list, + scalar keys %mob_nameID_obstacles, + scalar keys %player_name_obstacles, + scalar keys %area_spell_type_obstacles, + $plugin_settings{enable_avoid_portals} ? 1 : 0 + ), 'info'; + return; + } + + message "[" . PLUGIN_NAME . "] Usage: od [dump|reload|status]\n", 'list'; +} + +## Purpose: Dumps the main live obstacle caches for debugging. +## Args: none. +## Returns: nothing. +## Notes: This writes the raw structures to the log so debugging can inspect cached +## obstacle state without attaching a debugger. +sub use_dump { + warning "[" . PLUGIN_NAME . "] obstaclesList Dump: " . Dumper(\%obstaclesList); + warning "[" . PLUGIN_NAME . "] removed_obstacle_still_in_list Dump: " . Dumper(\%removed_obstacle_still_in_list); +} + +## Purpose: Clears live obstacle state when the map changes. +## Args: Hook arguments are ignored. +## Returns: nothing. +## Notes: Map change invalidates every live actor-based obstacle, so this resets +## state, clears caches, and rebuilds only static cell obstacles for the new field. +sub on_packet_mapChange { + %obstaclesList = (); + %removed_obstacle_still_in_list = (); + $mustRePath = 0; + reset_live_aggregate_state(); + rebuild_static_cell_obstacles_for_current_map(); +} + +## Purpose: Checks whether a target should be dropped because an obstacle is nearby. +## Args: `($hook, $target, $drop_string)` for logging and target inspection. +## Returns: `1` if the target should be dropped, otherwise `0`. +## Notes: It tests both the target's current pathfinding position and destination so +## fast-moving targets cannot slip through just because one position is stale. +sub should_drop_target_from_obstacle { + my ($hook, $target, $drop_string) = @_; + return 0 unless $target; + return 0 unless $field; + + my @target_positions; + my $target_calc_pos = calcPosFromPathfinding($field, $target); + push @target_positions, $target_calc_pos if $target_calc_pos; + + my $same_as_calc = $target_calc_pos + && $target_calc_pos->{x} == $target->{pos_to}{x} + && $target_calc_pos->{y} == $target->{pos_to}{y}; + push (@target_positions, $target->{pos_to}) unless $same_as_calc; + + my $is_dropped = isTargetDroppedObstacle($target); + foreach my $target_pos (@target_positions) { + my $obstacle = is_there_an_obstacle_near_pos($target_pos, 1); + if ($obstacle) { + warning "[" . PLUGIN_NAME . "] [$hook] $drop_string target $target because there is an obstacle nearby (" . ($obstacle->{name}) . ").\n" if !$is_dropped; + $target->{attackFailedObstacle} = 1; + return 1; + } + } + + if ($is_dropped) { + warning "[" . PLUGIN_NAME . "] [$hook] Releasing target $target from obstacle block.\n"; + $target->{attackFailedObstacle} = 0; + } + + return 0; +} + +## Purpose: Hook callback that forces the current attack target to be dropped. +## Args: `(undef, $args)` from `shouldDropTarget`. +## Returns: nothing directly; writes to `$args->{return}` when dropping. +## Notes: This keeps the core attack AI from tunneling into a target that has moved +## into an obstacle zone. +sub on_shouldDropTarget { + my ($hook, $args) = @_; + return unless $args->{target}; + + if (should_drop_target_from_obstacle($hook, $args->{target}, 'Dropping')) { + $args->{return} = 1; + } +} + +## Purpose: Filters obstacle-blocked targets out of the candidate target list. +## Args: `(undef, $args)` from `getBestTarget`. +## Returns: nothing directly; mutates `$args->{possibleTargets}`. +## Notes: This is the earlier, cheaper target-selection gate that prevents bad +## targets from being scored in the first place. +sub on_getBestTarget { + my ($hook, $args) = @_; + 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_obstacle($hook, $target, 'Not picking')) { + next; + } + + push @filtered_targets, $target_ID; + } + + @{ $args->{possibleTargets} } = @filtered_targets; +} + +## Purpose: Produces a readable obstacle name for logs and debug output. +## Args: `($obstacle)` which may be an actor, spell-like object, static-cell entry, +## or plain value. +## Returns: A best-effort human-readable name string. +## Notes: Many obstacle sources look different internally, so this helper keeps log +## messages understandable without leaking raw object structure details. +sub getObstacleName { + my ($obstacle) = @_; + return 'Unknown obstacle' unless $obstacle; + return $obstacle unless ref $obstacle; + + my $pos = get_actor_position($obstacle); + my $type = $obstacle->{type}; + + if (defined $type && $type eq 'cell') { + my $map_name = $obstacle->{map} || ($field ? $field->baseName : 'unknownField'); + return "CellsInMap $map_name $pos->{x} $pos->{y}"; + } + + if (defined $type && $type eq 'portal') { + return "Portal $pos->{x} $pos->{y}"; + } + + if (defined $obstacle->{name} && $obstacle->{name} ne '' && $obstacle->{name} !~ /^Unknown \#/) { + return $obstacle->{name}; + } + + if (UNIVERSAL::can($obstacle, 'name')) { + my $name = eval { $obstacle->name }; + return $name if defined $name && $name ne '' && $name !~ /^Unknown \#/; + } + + if (defined $obstacle->{type} && !ref $obstacle->{type} && $obstacle->{type} ne '') { + return "Spell $obstacle->{type}" if $obstacle->{type} =~ /^\d+$/; + return $obstacle->{type}; + } + + return "Unknown at $pos->{x} $pos->{y}" if $pos; + return $obstacle->{name} if defined $obstacle->{name} && $obstacle->{name} ne ''; + return 'Unknown obstacle'; +} + +## Purpose: Checks whether a target is currently flagged as obstacle-blocked. +## Args: `($target)`. +## Returns: `1` if the plugin previously marked it as blocked, otherwise `0`. +## Notes: This is used to avoid duplicate warnings and to release the flag once the +## target becomes safe again. +sub isTargetDroppedObstacle { + my ($target) = @_; + return 1 if exists $target->{attackFailedObstacle} && $target->{attackFailedObstacle} == 1; + return 0; +} + +## Purpose: Finds the first obstacle close enough to reject a target or destination. +## Args: `($pos, $type)` where `$type` selects target-drop or destination-drop rules. +## Returns: The matching obstacle hashref, or `undef` when none applies. +## Notes: This centralizes the distance checks used by both target filtering and +## destination/route validation logic. +sub is_there_an_obstacle_near_pos { + my ($pos, $type) = @_; + return unless $pos; + + foreach my $obstacle_ID (keys %obstaclesList) { + my $obstacle = $obstaclesList{$obstacle_ID}; + next unless $obstacle->{pos_to}; + + my $dist = blockDistance($pos, $obstacle->{pos_to}); + if ($type == 1) { + next unless defined $obstacle->{drop_target_when_near_dist} && $obstacle->{drop_target_when_near_dist} >= 0; + next unless $dist <= $obstacle->{drop_target_when_near_dist}; + return $obstacle; + } else { + next unless defined $obstacle->{drop_destination_when_near_dist} && $obstacle->{drop_destination_when_near_dist} >= 0; + next unless $dist <= $obstacle->{drop_destination_when_near_dist}; + return $obstacle; + } + } + + return; +} + +## Purpose: Returns the current prohibited-cell map for the live field. +## Args: none. +## Returns: A hashref of prohibited cells keyed by x/y with nearest-distance values. +## Notes: The implementation currently delegates to the live aggregate table, but +## keeping this wrapper makes the calling code independent from storage details. +sub build_prohibited_cells { + return \%cached_prohibited_cells; +} + +## Purpose: Returns the cached prohibited-cell map for the current field. +## Args: none. +## Returns: A prohibited-cell hashref, or an empty hashref when no field exists. +## Notes: The plugin invalidates this cache whenever obstacle contributions change, +## so callers can safely reuse it inside one routing cycle. +sub get_cached_prohibited_cells { + return {} unless $field; + return build_prohibited_cells(); +} + +## Purpose: Returns a cached copy of the field weight map with prohibited cells blocked. +## Args: `($base_weight_map_ref, $target_field, $prohibited_cells)`. +## Returns: A modified weight-map string, or `undef` when inputs are invalid. +## Notes: The canonical cache is only used for the current full prohibited-cell set. +## Route-specific filtered prohibited sets get their own uncached temporary clone so +## task-local filtering never reuses a stale blocked-map variant. +sub get_cached_weight_map_with_prohibited_cells { + my ($base_weight_map_ref, $target_field, $prohibited_cells) = @_; + return unless $base_weight_map_ref && $target_field && $prohibited_cells; + + my $canonical_prohibited_refaddr = refaddr(\%cached_prohibited_cells); + my $requested_prohibited_refaddr = refaddr($prohibited_cells); + if (!defined $canonical_prohibited_refaddr || !defined $requested_prohibited_refaddr || $canonical_prohibited_refaddr != $requested_prohibited_refaddr) { + return build_weight_map_with_prohibited_cells($base_weight_map_ref, $target_field->{width}, $prohibited_cells); + } + + my $field_name = $target_field->name; + if ( + !defined $cached_weight_map_with_prohibited + || !defined $cached_weight_map_field_name + || $cached_weight_map_field_name ne $field_name + ) { + $cached_weight_map_with_prohibited = ${$base_weight_map_ref}; + foreach my $x (keys %cached_prohibited_cells) { + foreach my $y (keys %{ $cached_prohibited_cells{$x} }) { + my $offset = getOffset($x, $target_field->{width}, $y); + substr($cached_weight_map_with_prohibited, $offset, 1) = pack('c', -1); + } + } + $cached_weight_map_field_name = $field_name; + } + + return $cached_weight_map_with_prohibited; +} + +## Purpose: Returns the merged danger-cell table for the live field. +## Args: none. +## Returns: A hashref of live danger values keyed by x/y. +## Notes: Like `build_prohibited_cells`, this wrapper hides the storage detail that +## danger cells are maintained incrementally in a live aggregate hash. +sub build_danger_cells { + return \%cached_danger_cells; +} + +## Purpose: Returns the cached danger-cell map for the current field. +## Args: none. +## Returns: A danger-cell hashref, or an empty hashref when no field exists. +## Notes: The danger table is already maintained live, so this wrapper mainly gives +## call sites a symmetric API with the prohibited-cell cache helpers. +sub get_cached_danger_cells { + return {} unless $field; + return build_danger_cells(); +} + +## Purpose: Sums the danger score of every node in a path solution. +## Args: `($solution, $danger_cells)`. +## Returns: The total numeric danger score for that path. +## Notes: Danger values are additive, so this is the shared primitive used for local +## path scoring, lag compensation, and route-slice comparison. +sub route_danger_score_from_cells { + my ($solution, $danger_cells) = @_; + return 0 unless $solution && @{$solution}; + return 0 unless $danger_cells; + + my $score = 0; + + foreach my $node (@{$solution}) { + next unless $node; + $score += ($danger_cells->{$node->{x}} && $danger_cells->{$node->{x}}{$node->{y}}) || 0; + } + + return $score; +} + +## Purpose: Sums danger only for a slice of a route solution. +## Args: `($solution, $danger_cells, $from_idx, $to_idx)`. +## Returns: The numeric danger score for that inclusive slice. +## Notes: This exists so route-step scoring can compare a partial local path with the +## remaining danger still present in the original route plan. +sub route_danger_score_for_slice { + my ($solution, $danger_cells, $from_idx, $to_idx) = @_; + return 0 unless $solution && @{$solution}; + return 0 unless defined $from_idx && defined $to_idx; + return 0 if $from_idx > $to_idx; + + my $slice = [ @{$solution}[$from_idx .. $to_idx] ]; + return route_danger_score_from_cells($slice, $danger_cells); +} + +## Purpose: Builds the candidate route_step indices that should be evaluated. +## Args: `($solution, $max_route_step)`. +## Returns: An arrayref of candidate route-step indices. +## Notes: It prefers step boundaries where movement direction changes, plus the full +## maximum step, because those are the most meaningful places to rescore local paths. +sub build_route_step_candidates { + my ($solution, $max_route_step) = @_; + my @candidates; + return \@candidates unless $solution && @{$solution}; + return \@candidates unless defined $max_route_step && $max_route_step >= 1; + + my %seen; + my $last_index = @{$solution} - 1; + $max_route_step = $last_index if $max_route_step > $last_index; + return \@candidates if $max_route_step < 1; + + if ($max_route_step >= 2) { + my $prev_dx = $solution->[1]{x} - $solution->[0]{x}; + my $prev_dy = $solution->[1]{y} - $solution->[0]{y}; + + for (my $i = 2; $i <= $max_route_step; $i++) { + my $dx = $solution->[$i]{x} - $solution->[$i - 1]{x}; + my $dy = $solution->[$i]{y} - $solution->[$i - 1]{y}; + if ($dx != $prev_dx || $dy != $prev_dy) { + my $boundary = $i - 1; + if ($boundary >= 1 && !$seen{$boundary}) { + push @candidates, $boundary; + $seen{$boundary} = 1; + } + } + $prev_dx = $dx; + $prev_dy = $dy; + } + } + + if (!$seen{$max_route_step}) { + push @candidates, $max_route_step; + } + + return \@candidates; +} + +## Purpose: Chooses the safest route_step by simulating local movement to each candidate. +## Args: `($current_pos, $solution, $max_route_step, $prohibited_cells, $danger_cells)`. +## Returns: `($best_step, $best_score)` when a safe candidate exists, otherwise empty. +## Notes: This is the heart of the plugin's route-step adjustment. It compares the +## local client path against prohibited cells and danger scores, and can force a +## repath when lag makes the live local path more dangerous than the planned route. +sub choose_best_route_step { + my ($current_pos, $solution, $max_route_step, $prohibited_cells, $danger_cells) = @_; + return unless $current_pos && $solution && @{$solution}; + return unless defined $max_route_step && $max_route_step >= 1; + + my $last_index = @{$solution} - 1; + $max_route_step = $last_index if $max_route_step > $last_index; + return unless $max_route_step >= 1; + + my ($best_step, $best_score, $best_solution, $best_pos); + my $route_start_lag = blockDistance($current_pos, $solution->[0]); + my $candidate_steps = build_route_step_candidates($solution, $max_route_step); + + my $expected_route_danger = route_danger_score_for_slice($solution, $danger_cells, 0, $max_route_step); + + #message "[" . PLUGIN_NAME . "] >>>>> Before best step candidates ". (scalar @{$candidate_steps}) ."\n", 'route'; + #message "[" . PLUGIN_NAME . "] max_route_step $max_route_step | expected_route_danger $expected_route_danger\n", 'route'; + + foreach my $candidate_step (reverse @{$candidate_steps}) { + my $candidate_pos = $solution->[$candidate_step]; + next unless $candidate_pos; + + my $client_solution = get_client_solution($field, $current_pos, $candidate_pos); + next unless $client_solution && @{$client_solution}; + + if (route_crosses_prohibited_cells($client_solution, $prohibited_cells)) { + #message "[" . PLUGIN_NAME . "] Dropped [step $candidate_step] [$candidate_pos->{x} $candidate_pos->{y}] bc prohibited_cells \n", 'route'; + next; + } + + my $score = route_danger_score_from_cells($client_solution, $danger_cells); + if ($candidate_step < $max_route_step) { + $score += route_danger_score_for_slice($solution, $danger_cells, $candidate_step + 1, $max_route_step); + } + + #message "[" . PLUGIN_NAME . "] [step $candidate_step] [$candidate_pos->{x} $candidate_pos->{y}] Danger $score\n", 'route'; + if (!defined $best_score || $score < $best_score) { + $best_step = $candidate_step; + $best_score = $score; + $best_solution = $client_solution; + $best_pos = $candidate_pos; + } + } + + if (defined $best_score) { + debug "[" . PLUGIN_NAME . "] chose route_step $best_step (max $max_route_step [same? ". (($best_step == $max_route_step) ? 1 : 0) ."]) at [$best_pos->{x} $best_pos->{y}].\n", 'route', 2; + debug "[" . PLUGIN_NAME . "] Danger score $best_score (expected route danger $expected_route_danger).\n", 'route'; + debug "[choose_best_route_step] [$current_pos->{x} $current_pos->{y}] [$best_pos->{x} $best_pos->{y}] Route Sol == ". join(' >> ', map { "$_->{x} $_->{y}" } @{$solution}[0..$best_step]) ."\n", 'route', 3; + + if ($expected_route_danger || $route_start_lag) { + debug "[choose_best_route_step] [$current_pos->{x} $current_pos->{y}] [$best_pos->{x} $best_pos->{y}] Best Sol == ". join(' >> ', map { "$_->{x} $_->{y}" } @{$best_solution}) ."\n", 'route', 3; + } + + if ($route_start_lag) { + my $fix_lag_solution = get_client_solution($field, $current_pos, $solution->[0]); + my $fix_lag_danger = route_danger_score_from_cells($fix_lag_solution, $danger_cells); + $fix_lag_danger -= ($danger_cells->{$solution->[0]{x}} && $danger_cells->{$solution->[0]{x}}{$solution->[0]{y}}) || 0; + my $ideal_danger = $expected_route_danger + $fix_lag_danger; + debug "[choose_best_route_step] [$current_pos->{x} $current_pos->{y}] [$best_pos->{x} $best_pos->{y}] Lag Sol == ". join(' >> ', map { "$_->{x} $_->{y}" } @{$fix_lag_solution}) ."\n", 'route', 2; + debug "[" . PLUGIN_NAME . "] Route_start_lag [$route_start_lag] | lag_danger [$fix_lag_danger] | predicted no lag danger [$ideal_danger]\n", 'route', 3; + + + if ($route_start_lag > 0 && $best_score > $expected_route_danger) { + debug "[" . PLUGIN_NAME . "] route_step reset requested: current_calc_pos lags $route_start_lag cells behind planned route start and local danger $best_score exceeds expected route danger $expected_route_danger.\n", 'route', 1; + return; + } + } + } + + return ($best_step, $best_score); +} + +## Purpose: Removes one obstacle's prohibited zone from a cell set. +## Args: `($filtered, $target_field, $center, $prohibited_dist)`. +## Returns: nothing; mutates `$filtered` in place. +## Notes: Route destinations sometimes intentionally land inside a portal or other +## special zone, so this helper carves out just that obstacle's hard zone. +sub remove_prohibited_zone_from_cells { + my ($filtered, $target_field, $center, $prohibited_dist) = @_; + return unless $filtered && $target_field && $center; + return unless defined $prohibited_dist && $prohibited_dist >= 0; + + my ($min_x, $min_y, $max_x, $max_y) = $target_field->getSquareEdgesFromCoord($center, $prohibited_dist); + foreach my $y ($min_y .. $max_y) { + foreach my $x ($min_x .. $max_x) { + next unless exists $filtered->{$x} && exists $filtered->{$x}{$y}; + my $distance = blockDistance({ x => $x, y => $y }, $center); + next if $distance > $prohibited_dist; + delete $filtered->{$x}{$y}; + delete $filtered->{$x} unless scalar keys %{ $filtered->{$x} }; + } + } +} + +## Purpose: Loosens prohibited cells around a route destination when needed. +## Args: `($task, $prohibited_cells, $target_field)`. +## Returns: The original or filtered prohibited-cell hashref. +## Notes: This exists so route tasks can still finish at destinations such as portals +## while keeping danger scoring and all other obstacle penalties intact. +sub filter_prohibited_cells_for_route_task { + my ($task, $prohibited_cells, $target_field) = @_; + return $prohibited_cells unless $task && $prohibited_cells && $target_field; + return $prohibited_cells unless $task->{dest} && $task->{dest}{pos}; + + my $dest = $task->{dest}{pos}; + my @matching_portals = grep { + my $obstacle = $obstaclesList{$_}; + my $match_dist = 7; + my $danger_distance = profile_max_distance($obstacle->{danger_dist}); + $match_dist = $danger_distance if defined $danger_distance && $danger_distance > $match_dist; + $match_dist = $obstacle->{prohibited_dist} if defined $obstacle->{prohibited_dist} && $obstacle->{prohibited_dist} > $match_dist; + $obstacle + && $obstacle->{type} + && $obstacle->{type} eq 'portal' + && $obstacle->{pos_to} + && blockDistance($obstacle->{pos_to}, $dest) <= $match_dist + } keys %obstaclesList; + + my $destination_is_portal_route = 0; + my $portal_lut_key = $target_field->baseName . " $dest->{x} $dest->{y}"; + $destination_is_portal_route = 1 if $portals_lut{$portal_lut_key} && $portals_lut{$portal_lut_key}{source}; + + my $needs_filter = 0; + + my %filtered = map { + my $x = $_; + $x => { %{ $prohibited_cells->{$x} } } + } keys %{$prohibited_cells}; + + foreach my $obstacle_id (keys %obstaclesList) { + my $obstacle = $obstaclesList{$obstacle_id}; + next unless defined $obstacle->{prohibited_dist} && $obstacle->{prohibited_dist} >= 0; + my $obstacle_pos = get_actor_position($obstacle); + next unless $obstacle_pos; + next if blockDistance($obstacle_pos, $dest) > $obstacle->{prohibited_dist}; + + remove_prohibited_zone_from_cells(\%filtered, $target_field, $obstacle_pos, $obstacle->{prohibited_dist}); + $needs_filter = 1; + } + + return $prohibited_cells unless $needs_filter || ($destination_is_portal_route && @matching_portals); + + foreach my $portal_id (@matching_portals) { + my $portal = $obstaclesList{$portal_id}; + next unless $destination_is_portal_route; + remove_prohibited_zone_from_cells(\%filtered, $target_field, $portal->{pos_to}, $portal->{prohibited_dist}); + } + + return \%filtered; +} + +## Purpose: Hook callback that adjusts `route_step` before Task::Route sends movement. +## Args: `(undef, $args)` from the `route_step` hook. +## Returns: nothing directly; mutates `$args->{route_step}` or requests repath. +## Notes: This is where the plugin injects local danger-aware route-step selection +## into the core movement loop. +sub on_route_step { + my (undef, $args) = @_; + return unless $plugin_settings{adjust_route_step}; + return unless scalar keys %obstaclesList; + return unless $args->{task}; + return unless $args->{solution} && @{ $args->{solution} }; + return unless $args->{current_calc_pos}; + return unless defined $args->{route_step}; + + my $max_route_step = $args->{route_step}; + my $max_index = @{ $args->{solution} } - 1; + $max_route_step = $max_index if $max_route_step > $max_index; + return if $max_route_step < 1; + + my $prohibited_cells = get_cached_prohibited_cells(); + $prohibited_cells = filter_prohibited_cells_for_route_task($args->{task}, $prohibited_cells, $field); + my $danger_cells = get_cached_danger_cells(); + my ($best_step, $best_score) = choose_best_route_step($args->{current_calc_pos}, $args->{solution}, $max_route_step, $prohibited_cells, $danger_cells); + if (!defined $best_step) { + warning "[" . PLUGIN_NAME . "] No safe local route_step found; local client path would cross a prohibited cell. Requesting repath.\n"; + $args->{task}{resetRoute} = 1; + return; + } + + if ($best_step != $args->{route_step}) { + debug "[" . PLUGIN_NAME . "] route_step adjusted from $args->{route_step} to $best_step (danger score $best_score).\n", 'route'; + $args->{route_step} = $best_step; + } +} + +## Purpose: Adds or refreshes one live obstacle entry. +## Args: `($actor, $obstacle, $type)` describing the source actor, obstacle profile, +## and logical obstacle type. +## Returns: nothing. +## Notes: This builds the obstacle's weight, danger, and prohibited contributions, +## handles cached re-adds safely, and marks routes for repath if the world changed. +sub add_obstacle { + my ($actor, $obstacle, $type) = @_; + return unless $actor && $obstacle; + + my $pos = get_actor_position($actor); + return unless $pos; + + if (exists $removed_obstacle_still_in_list{$actor->{ID}}) { + debug "[" . PLUGIN_NAME . "] Re-adding obstacle $actor after it returned to view.\n"; + remove_obstacle_contributions($actor->{ID}) if exists $obstaclesList{$actor->{ID}}; + delete $obstaclesList{$actor->{ID}}; + delete $removed_obstacle_still_in_list{$actor->{ID}}; + } + + debug "[" . PLUGIN_NAME . "] Adding obstacle $actor on location $pos->{x} $pos->{y}.\n"; + + remove_obstacle_contributions($actor->{ID}) if exists $obstaclesList{$actor->{ID}}; + + my $weight_changes = create_changes_array($pos, $obstacle); + + $obstaclesList{$actor->{ID}}{pos_to} = $pos; + $obstaclesList{$actor->{ID}}{weight} = $weight_changes; + $obstaclesList{$actor->{ID}}{prohibited_cells} = build_prohibited_cells_contribution($pos, $obstacle, $field); + $obstaclesList{$actor->{ID}}{danger_cells} = build_danger_cells_contribution($pos, $obstacle, $field); + $obstaclesList{$actor->{ID}}{type} = $type; + $obstaclesList{$actor->{ID}}{name} = getObstacleName($actor); + if ($type eq 'monster') { + $obstaclesList{$actor->{ID}}{nameID} = $actor->{nameID}; + } + + define_extras($actor->{ID}, $obstacle); + apply_obstacle_contributions($actor->{ID}); + $mustRePath = 1; +} + +## Purpose: Adds one configured static cell obstacle to the live cache. +## Args: `($map_name, $block_index, $cell_index, $pos, $obstacle)`. +## Returns: nothing. +## Notes: Static cell obstacles do not have actor objects, so they receive a stable +## synthetic ID derived from map, block, and coordinate. +sub add_static_cell_obstacle { + my ($map_name, $block_index, $cell_index, $pos, $obstacle) = @_; + return unless $field && $pos && $obstacle; + + my $id = join(':', 'cell', $map_name, $block_index, $cell_index, $pos->{x}, $pos->{y}); + my $weight_changes = create_changes_array($pos, $obstacle); + + $obstaclesList{$id}{pos_to} = { x => $pos->{x}, y => $pos->{y} }; + $obstaclesList{$id}{weight} = $weight_changes; + $obstaclesList{$id}{prohibited_cells} = build_prohibited_cells_contribution($pos, $obstacle, $field); + $obstaclesList{$id}{danger_cells} = build_danger_cells_contribution($pos, $obstacle, $field); + $obstaclesList{$id}{type} = 'cell'; + $obstaclesList{$id}{map} = $map_name; + $obstaclesList{$id}{name} = getObstacleName($obstaclesList{$id}); + + define_extras($id, $obstacle); + apply_obstacle_contributions($id); +} + +## Purpose: Copies frequently used obstacle metadata into the live obstacle entry. +## Args: `($ID, $obstacle)`. +## Returns: nothing. +## Notes: This keeps later lookups simple by storing the parsed drop, danger, +## prohibited, and penalty settings directly on the live entry. +sub define_extras { + my ($ID, $obstacle) = @_; + $obstaclesList{$ID}{drop_target_when_near_dist} = defined $obstacle->{drop_target_when_near_dist} ? $obstacle->{drop_target_when_near_dist} : -1; + $obstaclesList{$ID}{drop_destination_when_near_dist} = defined $obstacle->{drop_destination_when_near_dist} ? $obstacle->{drop_destination_when_near_dist} : -1; + $obstaclesList{$ID}{danger_dist} = defined $obstacle->{danger_dist} ? $obstacle->{danger_dist} : -1; + $obstaclesList{$ID}{prohibited_dist} = defined $obstacle->{prohibited_dist} ? $obstacle->{prohibited_dist} : -1; + $obstaclesList{$ID}{penalty_dist} = defined $obstacle->{penalty_dist} ? $obstacle->{penalty_dist} : -1; +} + +## Purpose: Updates one live obstacle after its source actor moved. +## Args: `($actor, $obstacle, $type)`. +## Returns: nothing. +## Notes: Movement handling is optional by config. When enabled, this removes the old +## contributions, rebuilds them at the new position, and requests a repath. +sub move_obstacle { + my ($actor, $obstacle, $type) = @_; + return unless $plugin_settings{enable_move}; + return unless $actor && $obstacle && exists $obstaclesList{$actor->{ID}}; + + my $pos = get_actor_position($actor); + return unless $pos; + + debug "[" . PLUGIN_NAME . "] Moving obstacle $actor to $pos->{x} $pos->{y}.\n"; + + remove_obstacle_contributions($actor->{ID}); + + my $weight_changes = create_changes_array($pos, $obstacle); + $obstaclesList{$actor->{ID}}{pos_to} = $pos; + $obstaclesList{$actor->{ID}}{weight} = $weight_changes; + $obstaclesList{$actor->{ID}}{prohibited_cells} = build_prohibited_cells_contribution($pos, $obstacle, $field); + $obstaclesList{$actor->{ID}}{danger_cells} = build_danger_cells_contribution($pos, $obstacle, $field); + apply_obstacle_contributions($actor->{ID}); + $mustRePath = 1; +} + +## Purpose: Removes an obstacle immediately or parks it in the hidden-obstacle cache. +## Args: `($actor, $type, $reason)`. +## Returns: nothing. +## Notes: Monsters and players that simply leave sight are kept cached temporarily so +## the bot does not instantly assume the danger vanished just because visibility did. +sub remove_obstacle { + my ($actor, $type, $reason) = @_; + return unless $plugin_settings{enable_remove}; + return unless $actor; + return if $type eq 'portal'; + + my $pos = get_actor_position($actor) || $obstaclesList{$actor->{ID}}{pos_to}; + + if (($type eq 'monster' || $type eq 'player') && defined $reason && $reason eq 'disappeared') { + $removed_obstacle_still_in_list{$actor->{ID}} = 1; + debug "[" . PLUGIN_NAME . "] Keeping obstacle $actor cached after it moved out of sight.\n"; + } else { + debug "[" . PLUGIN_NAME . "] Removing obstacle $actor from " . ($pos ? "$pos->{x} $pos->{y}" : 'unknown position') . ".\n"; + remove_obstacle_contributions($actor->{ID}); + delete $obstaclesList{$actor->{ID}}; + delete $removed_obstacle_still_in_list{$actor->{ID}}; + $mustRePath = 1; + } +} + +## Purpose: Returns the best-known position for an actor-like obstacle source. +## Args: `($actor)`. +## Returns: A `{ x => ..., y => ... }` hashref, or `undef`. +## Notes: Different object types expose either `pos_to` or `pos`, so this helper +## gives the rest of the plugin one consistent position accessor. +sub get_actor_position { + my ($actor) = @_; + return unless $actor; + return $actor->{pos_to} if $actor->{pos_to}; + return $actor->{pos} if $actor->{pos}; + return; +} + +## Purpose: Runs the plugin's periodic AI maintenance tasks. +## Args: Hook arguments are ignored. +## Returns: nothing. +## Notes: This keeps all per-tick maintenance in one place: route destination drops, +## stale hidden-obstacle cleanup, and repath dispatch. +sub on_AI_pre_manual { + on_AI_pre_manual_drop_route_dest_near_Obstacle(); + on_AI_pre_manual_removed_obstacle_still_in_list(); + on_AI_pre_manual_repath(); +} + +## Purpose: Drops route destinations that became unsafe because of nearby obstacles. +## Args: none. +## Returns: nothing. +## Notes: This mainly protects random-walk and lock-map routes from continuing toward +## a destination that is now effectively guarded by an obstacle. +sub on_AI_pre_manual_drop_route_dest_near_Obstacle { + return unless scalar keys %obstaclesList; + + my $skip = 0; + while (1) { + my $index = AI::findAction ('route', $skip); + last unless (defined $index); + my $args = AI::args($index); + my $task = get_task($args); + next unless $task; + next unless $task->{isRandomWalk} || ($task->{isToLockMap} && $field->baseName eq $config{lockMap}); + my $obstacle = is_there_an_obstacle_near_pos($task->{dest}{pos}, 2); + next unless $obstacle; + warning "[" . PLUGIN_NAME . "] Dropping current route because an obstacle appeared near its destination ($task->{dest}{pos}{x} $task->{dest}{pos}{y}) close to (" . ($obstacle->{name}) . ").\n"; + AI::clear('move', 'route'); + last; + } continue { + $skip++; + } +} + +## Purpose: Purges hidden cached obstacles once they should be visible again. +## Args: none. +## Returns: nothing. +## Notes: This solves the classic "left sight but may still exist" problem by keeping +## obstacles cached until the player gets close enough that the actor should reappear. +sub on_AI_pre_manual_removed_obstacle_still_in_list { + my @obstacles = keys %removed_obstacle_still_in_list; + return unless @obstacles; + + my $sight = ($config{clientSight} || 17) - 2; + my $realMyPos = calcPosFromPathfinding($field, $char); + return unless $realMyPos; + + OBSTACLE: foreach my $obstacle_ID (@obstacles) { + my $obstacle = $obstaclesList{$obstacle_ID}; + next OBSTACLE unless $obstacle && $obstacle->{pos_to}; + + my $dist = blockDistance($realMyPos, $obstacle->{pos_to}); + next OBSTACLE unless $dist < $sight; + + my $target = Actor::get($obstacle_ID); + next OBSTACLE if $target; + + debug "[" . PLUGIN_NAME . "] Removing cached obstacle $obstacle->{name} ($obstacle->{type}) from $obstacle->{pos_to}{x} $obstacle->{pos_to}{y}.\n"; + remove_obstacle_contributions($obstacle_ID); + delete $obstaclesList{$obstacle_ID}; + delete $removed_obstacle_still_in_list{$obstacle_ID}; + $mustRePath = 1; + } +} + +## Purpose: Triggers a route repath when obstacle changes marked routing as dirty. +## Args: none. +## Returns: nothing. +## Notes: Obstacle add/move/remove code only sets a flag; this helper emits the hook +## once per AI tick so repaths stay centralized and cheap. +sub on_AI_pre_manual_repath { + return unless $mustRePath; + debug "[" . PLUGIN_NAME . "] Requesting route repath if routing.\n"; + Plugins::callHook('routeRepath', { source => PLUGIN_NAME }); + $mustRePath = 0; +} + +## Purpose: Infers why an actor disappeared from view. +## Args: `($actor)`. +## Returns: One of `dead`, `teleported`, `disconnected`, `disappeared`, or `gone`. +## Notes: Removal policy depends on why the actor vanished, so this helper translates +## Network::Receive state flags into one plugin-specific reason string. +sub get_actor_disappearance_reason { + my ($actor) = @_; + return 'gone' unless $actor; + return 'dead' if $actor->{dead}; + return 'teleported' if $actor->{teleported}; + return 'disconnected' if $actor->{disconnected}; + return 'disappeared' if $actor->{disappeared}; + return 'gone'; +} +## Purpose: Extracts a concrete `Task::Route` from different AI containers. +## Args: `($args)` which may already be a route or a map-route wrapper. +## Returns: A `Task::Route` object, or `undef`. +## Notes: Different hooks hand route tasks to the plugin in different wrappers, so +## this helper normalizes that shape before route-specific logic runs. +sub get_task { + my ($args) = @_; + if (UNIVERSAL::isa($args, 'Task::Route')) { + return $args; + } elsif (UNIVERSAL::isa($args, 'Task::MapRoute') && $args->getSubtask && UNIVERSAL::isa($args->getSubtask, 'Task::Route')) { + return $args->getSubtask; + } + return; +} + +## Purpose: Injects obstacle weights and prohibited cells into live pathfinding calls. +## Args: `(undef, $args)` from `getRoute`. +## Returns: nothing directly; mutates route arguments in place. +## Notes: This is the main integration point with pathfinding. It overlays blocked +## cells and the live weight grid only for routes on the current live field. +sub on_getRoute { + my (undef, $args) = @_; + return unless scalar keys %obstaclesList; + return if !$field || $args->{field}->name ne $field->name; + + return unless ($args->{liveRoute}); + + my $prohibited_cells = get_cached_prohibited_cells(); + if ($args->{self} && ref $args->{self}) { + $prohibited_cells = filter_prohibited_cells_for_route_task($args->{self}, $prohibited_cells, $args->{field}); + } + my $base_weight_map_ref = defined $args->{weight_map} ? $args->{weight_map} : \($args->{field}->{weightMap}); + if ($prohibited_cells && scalar keys %{$prohibited_cells} && !pos_is_prohibited($args->{start}, $prohibited_cells) && !pos_is_prohibited($args->{dest}, $prohibited_cells)) { + if (defined $args->{weight_map}) { + $pathfinding_weight_map_override = build_weight_map_with_prohibited_cells($base_weight_map_ref, $args->{field}->{width}, $prohibited_cells); + } else { + $pathfinding_weight_map_override = get_cached_weight_map_with_prohibited_cells($base_weight_map_ref, $args->{field}, $prohibited_cells); + } + $args->{weight_map} = \$pathfinding_weight_map_override if defined $pathfinding_weight_map_override; + } + + $args->{customWeights} = 1; + $args->{secondWeightMap} = get_final_grid(); +} + +## Purpose: Converts an `(x, y)` coordinate into a linear weight-map offset. +## Args: `($x, $width, $y)`. +## Returns: The numeric byte offset into the field weight map. +## Notes: OpenKore weight maps are packed strings, so callers need this to edit a +## single cell in the cloned blocked weight map. +sub getOffset { + my ($x, $width, $y) = @_; + return (($y * $width) + $x); +} + +## Purpose: Returns the merged live obstacle weight grid used by pathfinding. +## Args: none. +## Returns: An arrayref of `{ x, y, weight }` entries. +## Notes: The result is cached per field and rebuilt from live aggregated weight +## sums only when obstacle state changed. +sub get_final_grid { + return [] unless $field; + return $cached_final_grid; +} + +## Purpose: Checks whether a coordinate lies inside a prohibited-cell set. +## Args: `($pos, $prohibited_cells)`. +## Returns: `1` if the coordinate is prohibited, otherwise `0`. +## Notes: This is used before overriding the route weight map so start/destination +## cells are not made impossible by mistake. +sub pos_is_prohibited { + my ($pos, $prohibited_cells) = @_; + return 0 unless $pos && $prohibited_cells; + return 0 unless exists $prohibited_cells->{$pos->{x}}; + return exists $prohibited_cells->{$pos->{x}}{$pos->{y}} ? 1 : 0; +} + +## Purpose: Clones a weight map and turns prohibited cells into hard blocks. +## Args: `($base_weight_map_ref, $width, $prohibited_cells)`. +## Returns: A modified packed weight-map string. +## Notes: Prohibited cells are represented by `-1` in the cloned map so core +## pathfinding treats them as unwalkable instead of merely expensive. +sub build_weight_map_with_prohibited_cells { + my ($base_weight_map_ref, $width, $prohibited_cells) = @_; + return unless $base_weight_map_ref && $width && $prohibited_cells; + + my $blocked_weight_map = ${$base_weight_map_ref}; + + foreach my $x (keys %{$prohibited_cells}) { + foreach my $y (keys %{ $prohibited_cells->{$x} }) { + my $offset = getOffset($x, $width, $y); + substr($blocked_weight_map, $offset, 1) = pack('c', -1); + } + } + + return $blocked_weight_map; +} + +## Purpose: Hook callback that contributes prohibited cells to external callers. +## Args: `(undef, $args)` containing `cells`, optional `field`, and optional `caller`. +## Returns: nothing directly; mutates `$args->{cells}`. +## Notes: This is used by systems such as route destination filtering and +## `meetingPosition`, including the special rule that dangerous meeting cells are also banned. +sub on_add_prohibited_cells { + my (undef, $args) = @_; + return unless $args && $args->{cells} && ref $args->{cells} eq 'HASH'; + + my $target_field = $args->{field}; + $target_field = $field if !$target_field || !UNIVERSAL::isa($target_field, 'Field'); + return unless $target_field; + + if ($field && $target_field->name eq $field->name) { + merge_prohibited_cells($args->{cells}, get_cached_prohibited_cells()); + + # Meeting-position targets must never land on a cell that is only marked as + # dangerous, otherwise we can deliberately run into ranged obstacle threat zones. + if (defined $args->{caller} && $args->{caller} eq 'meetingPosition') { + merge_marked_cells($args->{cells}, get_cached_danger_cells()); + } + } else { + merge_prohibited_cells($args->{cells}, build_prohibited_cells_for_field($target_field)); + } +} + +## Purpose: Merges one boolean cell-mark map into another. +## Args: `($target, $source)`. +## Returns: nothing; mutates `$target`. +## Notes: Unlike prohibited-cell merging, this treats presence as a simple mark and +## does not preserve any distance or weighted value metadata. +sub merge_marked_cells { + my ($target, $source) = @_; + return unless $target && $source; + + foreach my $x (keys %{$source}) { + foreach my $y (keys %{ $source->{$x} }) { + $target->{$x}{$y} = 1; + } + } +} + +## Purpose: Builds a boolean cell set around one center for destination dropping. +## Args: `($target_field, $center, $distance)`. +## Returns: A hashref of marked cells. +## Notes: This is used for "drop destination when near" logic, so it marks every +## walkable cell inside the configured block-distance radius. +sub build_drop_destination_cells_around_pos { + my ($target_field, $center, $distance) = @_; + return {} unless $target_field && $center; + return {} unless defined $distance && $distance >= 0; + + my %cells; + my ($min_x, $min_y, $max_x, $max_y) = $target_field->getSquareEdgesFromCoord($center, $distance); + foreach my $y ($min_y .. $max_y) { + foreach my $x ($min_x .. $max_x) { + next unless $target_field->isWalkable($x, $y); + next if blockDistance({ x => $x, y => $y }, $center) > $distance; + $cells{$x}{$y} = 1; + } + } + + return \%cells; +} + +## Purpose: Builds the full destination-drop cell set for a field. +## Args: `($target_field)`. +## Returns: A hashref of marked cells. +## Notes: On the live field it uses live obstacles; on other fields it falls back to +## configured static cell blocks only. +sub build_drop_destination_cells_for_field { + my ($target_field) = @_; + return {} unless $target_field; + + my %cells; + if ($field && $target_field->name eq $field->name) { + foreach my $obstacle_id (keys %obstaclesList) { + my $obstacle = $obstaclesList{$obstacle_id}; + next unless defined $obstacle->{drop_destination_when_near_dist} && $obstacle->{drop_destination_when_near_dist} >= 0; + my $obstacle_pos = get_actor_position($obstacle); + next unless $obstacle_pos; + merge_marked_cells(\%cells, build_drop_destination_cells_around_pos($target_field, $obstacle_pos, $obstacle->{drop_destination_when_near_dist})); + } + return \%cells; + } + + my $map_name = $target_field->baseName; + return \%cells unless defined $map_name && exists $cells_in_map_obstacles{$map_name}; + foreach my $block (@{ $cells_in_map_obstacles{$map_name} }) { + next unless $block->{config} && $block->{config}{enabled}; + next unless defined $block->{config}{drop_destination_when_near_dist} && $block->{config}{drop_destination_when_near_dist} >= 0; + foreach my $pos (@{ $block->{cells} || [] }) { + merge_marked_cells(\%cells, build_drop_destination_cells_around_pos($target_field, $pos, $block->{config}{drop_destination_when_near_dist})); + } + } + + return \%cells; +} + +## Purpose: Hook callback that contributes destination-drop cells to callers. +## Args: `(undef, $args)` containing `cells` and optional `field`. +## Returns: nothing directly; mutates `$args->{cells}`. +## Notes: This keeps destination filtering for other systems in sync with the same +## obstacle definitions used by routing. +sub on_add_drop_destination_cells { + my (undef, $args) = @_; + return unless $args && $args->{cells} && ref $args->{cells} eq 'HASH'; + + my $target_field = $args->{field}; + $target_field = $field if !$target_field || !UNIVERSAL::isa($target_field, 'Field'); + return unless $target_field; + + merge_marked_cells($args->{cells}, build_drop_destination_cells_for_field($target_field)); +} + +## Purpose: Builds the prohibited-cell map for an arbitrary field. +## Args: `($target_field)`. +## Returns: A prohibited-cell hashref for that field. +## Notes: The current live field can use live dynamic obstacles, while non-current +## fields can only rely on configured static cell obstacles. +sub build_prohibited_cells_for_field { + my ($target_field) = @_; + return {} unless $target_field; + + my %prohibited; + + if ($field && $target_field->name eq $field->name) { + merge_prohibited_cells(\%prohibited, \%cached_prohibited_cells); + } else { + merge_prohibited_cells(\%prohibited, build_static_prohibited_cells_for_field($target_field)); + } + return \%prohibited; +} + +## Purpose: Builds prohibited cells from configured static cell blocks for a field. +## Args: `($target_field)`. +## Returns: A prohibited-cell hashref. +## Notes: This is the fallback path for non-current maps where no live actor obstacle +## state exists and only config-defined cells can be considered. +sub build_static_prohibited_cells_for_field { + my ($target_field) = @_; + return {} unless $target_field; + + my %prohibited; + my $map_name = $target_field->baseName; + return \%prohibited unless defined $map_name && exists $cells_in_map_obstacles{$map_name}; + + foreach my $block (@{ $cells_in_map_obstacles{$map_name} }) { + next unless $block->{config} && $block->{config}{enabled}; + next unless defined $block->{config}{prohibited_dist} && $block->{config}{prohibited_dist} >= 0; + + foreach my $pos (@{ $block->{cells} || [] }) { + my ($min_x, $min_y, $max_x, $max_y) = $target_field->getSquareEdgesFromCoord($pos, $block->{config}{prohibited_dist}); + foreach my $y ($min_y .. $max_y) { + foreach my $x ($min_x .. $max_x) { + next unless $target_field->isWalkable($x, $y); + my $distance = blockDistance({ x => $x, y => $y }, $pos); + next if $distance > $block->{config}{prohibited_dist}; + if (!defined $prohibited{$x}{$y} || $distance < $prohibited{$x}{$y}) { + $prohibited{$x}{$y} = $distance; + } + } + } + } + } + + return \%prohibited; +} + +## Purpose: Merges one prohibited-cell map into another. +## Args: `($target, $source)`. +## Returns: nothing; mutates `$target`. +## Notes: If multiple obstacles cover the same cell, the merge keeps the nearest +## obstacle distance because that is the strongest prohibited-zone meaning. +sub merge_prohibited_cells { + my ($target, $source) = @_; + return unless $target && $source; + + foreach my $x (keys %{$source}) { + foreach my $y (keys %{ $source->{$x} }) { + my $distance = $source->{$x}{$y}; + if (!exists $target->{$x}{$y} || $distance < $target->{$x}{$y}) { + $target->{$x}{$y} = $distance; + } + } + } +} + +## Purpose: Calculates one inverse-square-like weight value for an obstacle distance. +## Args: `($ratio, $dist)`. +## Returns: The clamped weight contribution for that distance. +## Notes: Distance zero is treated as one to avoid division by zero and to ensure the +## obstacle center receives the strongest weight. +sub get_weight_for_block { + my ($ratio, $dist) = @_; + $dist = 1 if !$dist; + my $weight = int($ratio / ($dist * $dist)); + $weight = assertWeightBelowLimit($weight, $plugin_settings{weight_limit}); + return $weight; +} + +## Purpose: Caps a computed weight at the configured safety limit. +## Args: `($weight, $weight_limit)`. +## Returns: The original or capped weight value. +## Notes: Pathfinding weights have practical limits, so this keeps large penalty +## profiles from exploding the final grid. +sub assertWeightBelowLimit { + my ($weight, $weight_limit) = @_; + return $weight_limit if $weight >= $weight_limit; + return $weight; +} + +## Purpose: Builds the weighted influence area for one obstacle position. +## Args: `($obstacle_pos, $obstacle)`. +## Returns: An arrayref of `{ x, y, weight }` contributions. +## Notes: It scans the square covering the obstacle's penalty profile radius and +## records only positive walkable-cell contributions. +sub create_changes_array { + my ($obstacle_pos, $obstacle) = @_; + return [] unless $field && $obstacle_pos && $obstacle; + + my %local_obstacle = %{$obstacle}; + my $penalty_profile = $local_obstacle{penalty_dist}; + my $max_distance = profile_max_distance($penalty_profile); + return [] unless defined $max_distance && $max_distance >= 0; + + my @changes_array; + my ($min_x, $min_y, $max_x, $max_y) = $field->getSquareEdgesFromCoord($obstacle_pos, $max_distance); + + foreach my $y ($min_y .. $max_y) { + foreach my $x ($min_x .. $max_x) { + next unless $field->isWalkable($x, $y); + my $pos = { x => $x, y => $y }; + my $distance = blockDistance($pos, $obstacle_pos); + my $delta_weight = penalty_profile_value_at_distance($penalty_profile, $distance); + next unless defined $delta_weight && $delta_weight > 0; + $delta_weight = assertWeightBelowLimit($delta_weight, $plugin_settings{weight_limit}); + push @changes_array, { + x => $x, + y => $y, + weight => $delta_weight + }; + } + } + + @changes_array = sort { $b->{weight} <=> $a->{weight} } @changes_array; + return \@changes_array; +} + +## Purpose: Builds the prohibited-cell contribution produced by one obstacle. +## Args: `($obstacle_pos, $obstacle, $target_field)`. +## Returns: A prohibited-cell hashref for just that obstacle. +## Notes: Distances are stored so later merges can preserve the nearest covering +## obstacle when multiple prohibited zones overlap. +sub build_prohibited_cells_contribution { + my ($obstacle_pos, $obstacle, $target_field) = @_; + return {} unless $obstacle_pos && $obstacle && $target_field; + return {} unless defined $obstacle->{prohibited_dist} && $obstacle->{prohibited_dist} >= 0; + + my %prohibited; + my ($min_x, $min_y, $max_x, $max_y) = $target_field->getSquareEdgesFromCoord($obstacle_pos, $obstacle->{prohibited_dist}); + foreach my $y ($min_y .. $max_y) { + foreach my $x ($min_x .. $max_x) { + next unless $target_field->isWalkable($x, $y); + my $distance = blockDistance({ x => $x, y => $y }, $obstacle_pos); + next if $distance > $obstacle->{prohibited_dist}; + $prohibited{$x}{$y} = $distance; + } + } + + return \%prohibited; +} + +## Purpose: Builds the danger-cell contribution produced by one obstacle. +## Args: `($obstacle_pos, $obstacle, $target_field)`. +## Returns: A danger-cell hashref for just that obstacle. +## Notes: Danger values are additive, so overlapping obstacles can later be summed +## cell by cell in the live aggregate table. +sub build_danger_cells_contribution { + my ($obstacle_pos, $obstacle, $target_field) = @_; + return {} unless $obstacle_pos && $obstacle && $target_field; + + my $max_distance = profile_max_distance($obstacle->{danger_dist}); + return {} unless defined $max_distance && $max_distance >= 0; + + my %danger; + my ($min_x, $min_y, $max_x, $max_y) = $target_field->getSquareEdgesFromCoord($obstacle_pos, $max_distance); + foreach my $y ($min_y .. $max_y) { + foreach my $x ($min_x .. $max_x) { + next unless $target_field->isWalkable($x, $y); + my $distance = blockDistance({ x => $x, y => $y }, $obstacle_pos); + my $value = danger_profile_value_at_distance($obstacle->{danger_dist}, $distance); + next unless $value > 0; + $danger{$x}{$y} = $value; + } + } + + return \%danger; +} + +## Purpose: Adds one obstacle's weight contributions into the live aggregate table. +## Args: `($changes)` as built by `create_changes_array`. +## Returns: nothing. +## Notes: The live aggregate tables let the plugin update incrementally instead of +## rebuilding every cell contribution from scratch on each change. +sub add_weight_contribution { + my ($changes) = @_; + return unless $changes; + + foreach my $change (@{$changes}) { + next unless $change; + $cached_weight_map{$change->{x}}{$change->{y}} += $change->{weight}; + my $cell_key = "$change->{x},$change->{y}"; + my $clamped_weight = assertWeightBelowLimit($cached_weight_map{$change->{x}}{$change->{y}}, $plugin_settings{weight_limit}); + if (exists $cached_final_grid_index{$cell_key}) { + $cached_final_grid->[$cached_final_grid_index{$cell_key}]{weight} = $clamped_weight; + } else { + push @{$cached_final_grid}, { + x => $change->{x}, + y => $change->{y}, + weight => $clamped_weight + }; + $cached_final_grid_index{$cell_key} = $#{$cached_final_grid}; + } + } +} + +## Purpose: Removes one obstacle's weight contributions from the live aggregate table. +## Args: `($changes)` as previously added to the live aggregate. +## Returns: nothing. +## Notes: Empty cells are pruned as sums drop to zero so caches stay compact. +sub remove_weight_contribution { + my ($changes) = @_; + return unless $changes; + + foreach my $change (@{$changes}) { + next unless $change; + next unless exists $cached_weight_map{$change->{x}} && exists $cached_weight_map{$change->{x}}{$change->{y}}; + $cached_weight_map{$change->{x}}{$change->{y}} -= $change->{weight}; + + my $cell_key = "$change->{x},$change->{y}"; + if ($cached_weight_map{$change->{x}}{$change->{y}} <= 0) { + delete $cached_weight_map{$change->{x}}{$change->{y}}; + delete $cached_weight_map{$change->{x}} unless scalar keys %{ $cached_weight_map{$change->{x}} }; + + if (exists $cached_final_grid_index{$cell_key}) { + my $remove_index = delete $cached_final_grid_index{$cell_key}; + my $last_index = $#{$cached_final_grid}; + if ($remove_index != $last_index) { + my $moved = $cached_final_grid->[$last_index]; + $cached_final_grid->[$remove_index] = $moved; + $cached_final_grid_index{"$moved->{x},$moved->{y}"} = $remove_index; + } + pop @{$cached_final_grid}; + } + } elsif (exists $cached_final_grid_index{$cell_key}) { + $cached_final_grid->[$cached_final_grid_index{$cell_key}]{weight} = assertWeightBelowLimit($cached_weight_map{$change->{x}}{$change->{y}}, $plugin_settings{weight_limit}); + } + } +} + +## Purpose: Adds one numeric x/y grid into another by summing cell values. +## Args: `($target, $source)`. +## Returns: nothing; mutates `$target`. +## Notes: This generic helper is currently used for additive danger grids. +sub add_grid_sum_contribution { + my ($target, $source) = @_; + return unless $target && $source; + + foreach my $x (keys %{$source}) { + foreach my $y (keys %{ $source->{$x} }) { + $target->{$x}{$y} += $source->{$x}{$y}; + } + } +} + +## Purpose: Removes one numeric x/y grid from another. +## Args: `($target, $source)`. +## Returns: nothing; mutates `$target`. +## Notes: Cells and rows are deleted once their summed value reaches zero so the +## aggregate grid stays sparse. +sub remove_grid_sum_contribution { + my ($target, $source) = @_; + return unless $target && $source; + + foreach my $x (keys %{$source}) { + next unless exists $target->{$x}; + foreach my $y (keys %{ $source->{$x} }) { + next unless exists $target->{$x}{$y}; + $target->{$x}{$y} -= $source->{$x}{$y}; + delete $target->{$x}{$y} if $target->{$x}{$y} <= 0; + } + delete $target->{$x} unless scalar keys %{ $target->{$x} }; + } +} + +## Purpose: Adds one obstacle's prohibited contribution into the live aggregate state. +## Args: `($source)` prohibited-cell hashref for one obstacle. +## Returns: nothing. +## Notes: This tracks counts by distance so removing one obstacle later can restore +## the next-nearest distance if multiple prohibited zones overlap. +sub add_prohibited_contribution { + my ($source) = @_; + return unless $source; + + my $can_update_blocked_weight_map = $field + && defined $cached_weight_map_with_prohibited + && defined $cached_weight_map_field_name + && $cached_weight_map_field_name eq $field->name; + + foreach my $x (keys %{$source}) { + foreach my $y (keys %{ $source->{$x} }) { + my $distance = $source->{$x}{$y}; + my $was_prohibited = exists $cached_prohibited_cells{$x} && exists $cached_prohibited_cells{$x}{$y}; + $cached_prohibited_distance_counts{$x}{$y}{$distance}++; + $cached_prohibited_cell_counts{$x}{$y}++; + if (!exists $cached_prohibited_cells{$x}{$y} || $distance < $cached_prohibited_cells{$x}{$y}) { + $cached_prohibited_cells{$x}{$y} = $distance; + } + if ($can_update_blocked_weight_map && !$was_prohibited) { + my $offset = getOffset($x, $field->{width}, $y); + substr($cached_weight_map_with_prohibited, $offset, 1) = pack('c', -1); + } + } + } +} + +## Purpose: Removes one obstacle's prohibited contribution from the live aggregate state. +## Args: `($source)` prohibited-cell hashref for one obstacle. +## Returns: nothing. +## Notes: Distance counters allow the plugin to recompute the nearest remaining +## prohibited distance at each cell without rebuilding everything. +sub remove_prohibited_contribution { + my ($source) = @_; + return unless $source; + + my $can_update_blocked_weight_map = $field + && defined $cached_weight_map_with_prohibited + && defined $cached_weight_map_field_name + && $cached_weight_map_field_name eq $field->name; + + foreach my $x (keys %{$source}) { + next unless exists $cached_prohibited_distance_counts{$x}; + foreach my $y (keys %{ $source->{$x} }) { + next unless exists $cached_prohibited_distance_counts{$x}{$y}; + my $distance = $source->{$x}{$y}; + next unless exists $cached_prohibited_distance_counts{$x}{$y}{$distance}; + $cached_prohibited_distance_counts{$x}{$y}{$distance}--; + delete $cached_prohibited_distance_counts{$x}{$y}{$distance} if $cached_prohibited_distance_counts{$x}{$y}{$distance} <= 0; + $cached_prohibited_cell_counts{$x}{$y}-- if exists $cached_prohibited_cell_counts{$x} && exists $cached_prohibited_cell_counts{$x}{$y}; + delete $cached_prohibited_cell_counts{$x}{$y} if exists $cached_prohibited_cell_counts{$x} && exists $cached_prohibited_cell_counts{$x}{$y} && $cached_prohibited_cell_counts{$x}{$y} <= 0; + delete $cached_prohibited_cell_counts{$x} if exists $cached_prohibited_cell_counts{$x} && !scalar keys %{ $cached_prohibited_cell_counts{$x} }; + + if (!scalar keys %{ $cached_prohibited_distance_counts{$x}{$y} }) { + delete $cached_prohibited_distance_counts{$x}{$y}; + delete $cached_prohibited_cells{$x}{$y} if exists $cached_prohibited_cells{$x}; + if ($can_update_blocked_weight_map) { + my $offset = getOffset($x, $field->{width}, $y); + substr($cached_weight_map_with_prohibited, $offset, 1) = substr($field->{weightMap}, $offset, 1); + } + } elsif (exists $cached_prohibited_cells{$x} && exists $cached_prohibited_cells{$x}{$y} && $cached_prohibited_cells{$x}{$y} == $distance) { + my ($nearest) = sort { $a <=> $b } keys %{ $cached_prohibited_distance_counts{$x}{$y} }; + $cached_prohibited_cells{$x}{$y} = $nearest; + } + + delete $cached_prohibited_distance_counts{$x} unless scalar keys %{ $cached_prohibited_distance_counts{$x} }; + delete $cached_prohibited_cells{$x} if exists $cached_prohibited_cells{$x} && !scalar keys %{ $cached_prohibited_cells{$x} }; + } + } +} + +## Purpose: Applies one live obstacle's cached contributions to every aggregate table. +## Args: `($obstacle_id)`. +## Returns: nothing. +## Notes: This is the one place where a live obstacle becomes visible to routing: +## prohibited cells, danger cells, and weights are all merged here together. +sub apply_obstacle_contributions { + my ($obstacle_id) = @_; + my $obstacle = $obstaclesList{$obstacle_id}; + return unless $obstacle; + + add_prohibited_contribution($obstacle->{prohibited_cells}); + add_grid_sum_contribution(\%cached_danger_cells, $obstacle->{danger_cells}); + add_weight_contribution($obstacle->{weight}); +} + +## Purpose: Removes one live obstacle's cached contributions from aggregate tables. +## Args: `($obstacle_id)`. +## Returns: nothing. +## Notes: This is the exact inverse of `apply_obstacle_contributions` and is called +## before moving, deleting, or rebuilding an obstacle entry. +sub remove_obstacle_contributions { + my ($obstacle_id) = @_; + my $obstacle = $obstaclesList{$obstacle_id}; + return unless $obstacle; + + remove_prohibited_contribution($obstacle->{prohibited_cells}); + remove_grid_sum_contribution(\%cached_danger_cells, $obstacle->{danger_cells}); + remove_weight_contribution($obstacle->{weight}); +} + +## Purpose: Returns the incrementally maintained final obstacle weight grid. +## Args: none. +## Returns: An arrayref of `{ x, y, weight }` entries. +## Notes: Pathfinding consumes this sparse list as the second weight map layered on +## top of the field's normal weights. The list is updated cell-by-cell as obstacles change. +sub sum_all_changes { + return $cached_final_grid; +} + +## Purpose: Returns the configured obstacle profile for a player actor. +## Args: `($actor)`. +## Returns: The player obstacle config hashref, or `undef`. +## Notes: Player lookup is name-based and case-insensitive because player IDs are not +## stable configuration identifiers. +sub get_player_obstacle { + my ($actor) = @_; + return unless $actor && defined $actor->{name}; + + my $obstacle = $player_name_obstacles{lc $actor->{name}}; + return unless $obstacle && $obstacle->{enabled}; + return $obstacle; +} + +## Purpose: Hook callback that adds configured player obstacles when players appear. +## Args: `(undef, $actor)`. +## Returns: nothing. +## Notes: This is a thin hook adapter around `get_player_obstacle` and `add_obstacle`. +sub on_add_player_list { + my (undef, $actor) = @_; + my $obstacle = get_player_obstacle($actor); + add_obstacle($actor, $obstacle, 'player') if $obstacle; +} + +## Purpose: Hook callback that updates configured player obstacles on movement. +## Args: `(undef, $actor)`. +## Returns: nothing. +## Notes: Only already-tracked player obstacles are updated, which avoids needless +## work for unrelated players. +sub on_player_moved { + my (undef, $actor) = @_; + return unless $actor && exists $obstaclesList{$actor->{ID}}; + + my $obstacle = get_player_obstacle($actor); + move_obstacle($actor, $obstacle, 'player') if $obstacle; +} + +## Purpose: Hook callback that removes configured player obstacles on disappearance. +## Args: `(undef, $args)` containing the disappearing player. +## Returns: nothing. +## Notes: The actual removal policy is delegated to `remove_obstacle`, which may +## cache the obstacle instead of deleting it immediately. +sub on_player_disappeared { + my (undef, $args) = @_; + my $actor = $args->{player}; + return unless $actor && exists $obstaclesList{$actor->{ID}}; + remove_obstacle($actor, 'player', get_actor_disappearance_reason($actor)); +} + +## Purpose: Returns the configured obstacle profile for a monster actor. +## Args: `($actor)`. +## Returns: The monster obstacle config hashref, or `undef`. +## Notes: Monster lookup is keyed by `nameID`, which is stable across instances. +sub get_monster_obstacle { + my ($actor) = @_; + return unless $actor && defined $actor->{nameID}; + + my $obstacle = $mob_nameID_obstacles{$actor->{nameID}}; + return unless $obstacle && $obstacle->{enabled}; + return $obstacle; +} + +## Purpose: Hook callback that adds configured monster obstacles when monsters appear. +## Args: `(undef, $actor)`. +## Returns: nothing. +## Notes: This is a thin adapter that keeps the monster hook path parallel to player +## and spell obstacle registration. +sub on_add_monster_list { + my (undef, $actor) = @_; + my $obstacle = get_monster_obstacle($actor); + add_obstacle($actor, $obstacle, 'monster') if $obstacle; +} + +## Purpose: Hook callback that updates configured monster obstacles on movement. +## Args: `(undef, $actor)`. +## Returns: nothing. +## Notes: Only tracked monster obstacles are updated, and actual movement handling is +## still gated by the plugin's `enable_move` setting. +sub on_monster_moved { + my (undef, $actor) = @_; + return unless $actor && exists $obstaclesList{$actor->{ID}}; + + my $obstacle = get_monster_obstacle($actor); + move_obstacle($actor, $obstacle, 'monster') if $obstacle; +} + +## Purpose: Hook callback that removes configured monster obstacles on disappearance. +## Args: `(undef, $args)` containing the disappearing monster. +## Returns: nothing. +## Notes: Hidden monsters may be cached briefly depending on disappearance reason and +## plugin settings. +sub on_monster_disappeared { + my (undef, $args) = @_; + my $actor = $args->{monster}; + return unless $actor && exists $obstaclesList{$actor->{ID}}; + remove_obstacle($actor, 'monster', get_actor_disappearance_reason($actor)); +} + +## Purpose: Returns the configured obstacle profile for an area spell instance. +## Args: `($spell)`. +## Returns: The spell obstacle config hashref, or `undef`. +## Notes: Spell avoidance is keyed by spell type rather than by unique spell ID. +sub get_spell_obstacle { + my ($spell) = @_; + return unless $spell && defined $spell->{type}; + + my $obstacle = $area_spell_type_obstacles{$spell->{type}}; + return unless $obstacle && $obstacle->{enabled}; + return $obstacle; +} + +## Purpose: Hook callback that adds configured spell obstacles when spells appear. +## Args: `(undef, $args)` from `packet_areaSpell`. +## Returns: nothing. +## Notes: The hook provides the spell ID, so this helper resolves the live spell +## object before attempting to add the obstacle. +sub on_add_areaSpell_list { + my (undef, $args) = @_; + my $ID = $args->{ID}; + my $spell = $spells{$ID}; + return unless $spell; + + my $obstacle = get_spell_obstacle($spell); + add_obstacle($spell, $obstacle, 'spell') if $obstacle; +} + +## Purpose: Hook callback that removes spell obstacles when spells disappear. +## Args: `(undef, $args)` containing the spell ID. +## Returns: nothing. +## Notes: The live spell object may already be missing, so the helper falls back to a +## minimal stub object carrying the ID. +sub on_areaSpell_disappeared { + my (undef, $args) = @_; + my $ID = $args->{ID}; + return unless $ID && exists $obstaclesList{$ID}; + + my $spell = $spells{$ID} || { ID => $ID }; + remove_obstacle($spell, 'spell', 'gone'); +} + +## Purpose: Returns the active portal obstacle profile when portal avoidance is enabled. +## Args: none. +## Returns: A cloned portal obstacle config hashref, or `undef`. +## Notes: The config is cloned so per-portal live entries can be modified safely +## without mutating the shared default profile. +sub get_portal_obstacle { + return unless $plugin_settings{enable_avoid_portals}; + my %obstacle = %default_portal_obstacle; + return \%obstacle if $obstacle{enabled}; + return; +} + +## Purpose: Hook callback that adds portal obstacles when portals appear. +## Args: `(undef, $actor)`. +## Returns: nothing. +## Notes: Portal obstacles are opt-in via config and use the shared default portal +## profile returned by `get_portal_obstacle`. +sub on_add_portal_list { + my (undef, $actor) = @_; + my $obstacle = get_portal_obstacle(); + add_obstacle($actor, $obstacle, 'portal') if $obstacle; +} + +## Purpose: Ignores portal disappearance events. +## Args: none. +## Returns: nothing. +## Notes: Portal obstacles are fully rebuilt on map change, so individual disappear +## handling is intentionally unnecessary. +sub on_portal_disappeared { + return; +} + +## Purpose: Marks player or monster obstacles as temporarily hidden. +## Args: `(undef, $args)` containing the actor about to be avoided for removal. +## Returns: nothing. +## Notes: This hook exists so the plugin can preserve obstacle influence briefly when +## an actor leaves view, then purge it later only if it truly should have reappeared. +sub on_actor_avoid_removal { + my (undef, $args) = @_; + my $actor = $args->{actor}; + return unless $actor && exists $obstaclesList{$actor->{ID}}; + + my $type; + if ($actor->isa('Actor::Player')) { + $type = 'player'; + } elsif ($actor->isa('Actor::Monster')) { + $type = 'monster'; + } else { + return; + } + + debug "[" . PLUGIN_NAME . "] [on_actor_avoid_removal] $actor\n", 'route'; + remove_obstacle($actor, $type, 'disappeared'); +} + +1; diff --git a/plugins/eventMacro/eventMacro/Condition/ConfigKey.pm b/plugins/eventMacro/eventMacro/Condition/ConfigKey.pm index 6a75816b59..0d6146bd5d 100644 --- a/plugins/eventMacro/eventMacro/Condition/ConfigKey.pm +++ b/plugins/eventMacro/eventMacro/Condition/ConfigKey.pm @@ -9,7 +9,7 @@ use eventMacro::Data qw( $general_wider_variable_qr ); use eventMacro::Utilities qw( find_variable ); sub _hooks { - ['post_configModify','pos_load_config.txt','in_game']; + ['post_configModify','post_bulkConfigModify','pos_load_config.txt','in_game']; } sub _parse_syntax { diff --git a/plugins/eventMacro/eventMacro/Condition/ConfigKeyDefined.pm b/plugins/eventMacro/eventMacro/Condition/ConfigKeyDefined.pm index 401005dd6b..18691e6e39 100644 --- a/plugins/eventMacro/eventMacro/Condition/ConfigKeyDefined.pm +++ b/plugins/eventMacro/eventMacro/Condition/ConfigKeyDefined.pm @@ -8,7 +8,7 @@ use Globals qw( %config ); use eventMacro::Utilities qw( find_variable ); sub _hooks { - ['post_configModify','pos_load_config.txt','in_game']; + ['post_configModify','post_bulkConfigModify','pos_load_config.txt','in_game']; } sub _parse_syntax { diff --git a/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualDifferentDefinedValue.pm b/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualDifferentDefinedValue.pm index 5c37abd6fc..97d3ddc629 100644 --- a/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualDifferentDefinedValue.pm +++ b/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualDifferentDefinedValue.pm @@ -7,7 +7,7 @@ use base 'eventMacro::Condition'; use Globals qw( %config ); sub _hooks { - ['post_configModify','pos_load_config.txt','in_game']; + ['post_configModify','post_bulkConfigModify','pos_load_config.txt','in_game']; } sub _parse_syntax { diff --git a/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualSameDefinedValue.pm b/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualSameDefinedValue.pm index 8b45f02cb5..52e0b54ef4 100644 --- a/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualSameDefinedValue.pm +++ b/plugins/eventMacro/eventMacro/Condition/ConfigKeyDualSameDefinedValue.pm @@ -7,7 +7,7 @@ use base 'eventMacro::Condition'; use Globals qw( %config ); sub _hooks { - ['post_configModify','pos_load_config.txt','in_game']; + ['post_configModify','post_bulkConfigModify','pos_load_config.txt','in_game']; } sub _parse_syntax { diff --git a/plugins/eventMacro/eventMacro/Condition/ConfigKeyNot.pm b/plugins/eventMacro/eventMacro/Condition/ConfigKeyNot.pm index d8c41a6cab..feefc28914 100644 --- a/plugins/eventMacro/eventMacro/Condition/ConfigKeyNot.pm +++ b/plugins/eventMacro/eventMacro/Condition/ConfigKeyNot.pm @@ -9,7 +9,7 @@ use eventMacro::Data qw( $general_wider_variable_qr ); use eventMacro::Utilities qw( find_variable ); sub _hooks { - ['post_configModify','pos_load_config.txt','in_game']; + ['post_configModify','post_bulkConfigModify','pos_load_config.txt','in_game']; } sub _parse_syntax { diff --git a/plugins/eventMacro/eventMacro/Condition/ConfigKeyNotExist.pm b/plugins/eventMacro/eventMacro/Condition/ConfigKeyNotExist.pm index f9a20292ac..7ffef3d90b 100644 --- a/plugins/eventMacro/eventMacro/Condition/ConfigKeyNotExist.pm +++ b/plugins/eventMacro/eventMacro/Condition/ConfigKeyNotExist.pm @@ -8,7 +8,7 @@ use Globals qw( %config ); use eventMacro::Utilities qw( find_variable ); sub _hooks { - ['post_configModify','pos_load_config.txt','in_game']; + ['post_configModify','post_bulkConfigModify','pos_load_config.txt','in_game']; } sub _parse_syntax { diff --git a/plugins/eventMacro/eventMacro/Condition/ConfigKeyUndefined.pm b/plugins/eventMacro/eventMacro/Condition/ConfigKeyUndefined.pm index f85a463d1e..4214c03844 100644 --- a/plugins/eventMacro/eventMacro/Condition/ConfigKeyUndefined.pm +++ b/plugins/eventMacro/eventMacro/Condition/ConfigKeyUndefined.pm @@ -8,7 +8,7 @@ use Globals qw( %config ); use eventMacro::Utilities qw( find_variable ); sub _hooks { - ['post_configModify','pos_load_config.txt','in_game']; + ['post_configModify','post_bulkConfigModify','pos_load_config.txt','in_game']; } sub _parse_syntax { diff --git a/src/AI/Attack.pm b/src/AI/Attack.pm index 4487ad8324..1e3403d183 100644 --- a/src/AI/Attack.pm +++ b/src/AI/Attack.pm @@ -68,9 +68,6 @@ sub process { if (targetGone($ataqArgs, $ID)) { finishAttacking($ataqArgs, $ID); return; - } elsif (shouldGiveUp($ataqArgs, $ID)) { - giveUp($ataqArgs, $ID, 0); - return; } my $target = Actor::get($ID); @@ -85,6 +82,27 @@ sub process { my $effectiveAttackMode = getEffectiveAttackOnRoute($routeArgs); my $assistParty = ($effectiveAttackMode >= 1 && $config{'attackAuto_party'}) ? 1 : 0; my $target_is_aggressive = is_aggressive($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{return} = 0; + Plugins::callHook('shouldDropTarget' => \%plugin_args); + if ($plugin_args{return}) { + giveUp($ataqArgs, $ID, 2); + return; + } + + if (shouldGiveUp($ataqArgs, $ID)) { + message T("Can't reach or damage target\n"), "ai_attack"; + giveUp($ataqArgs, $ID, 0); + return; + } + if ($config{attackChangeTarget}) { my $aggressiveType = ($effectiveAttackMode >= 2) ? 2 : 0; my @aggressives = $effectiveAttackMode >= 0 ? ai_getAggressives($aggressiveType, $assistParty) : (); @@ -118,7 +136,6 @@ sub process { return; } - my $control = mon_control($target->{name},$target->{nameID}); if ($control->{attack_auto} == 3 && ($target->{dmgToYou} || $target->{missedYou} || $target->{dmgFromYou})) { message TF("Dropping target - %s (%s) has been provoked\n", $target->{name}, $target->{binID}); $char->sendAttackStop; @@ -127,16 +144,6 @@ sub process { return; } - 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{return} = 0; - Plugins::callHook('AI::Attack::process' => \%plugin_args); - return if ($plugin_args{return}); - if ($stage == MOVING_TO_ATTACK) { # Check for hidden monsters if (($target->{statuses}->{EFFECTSTATE_BURROW} || $target->{statuses}->{EFFECTSTATE_HIDING}) && $config{avoidHiddenMonsters}) { @@ -231,18 +238,18 @@ sub shouldGiveUp { } sub giveUp { - my ($args, $ID, $LOS) = @_; + my ($args, $ID, $reason) = @_; my $target = Actor::get($ID); if ($monsters{$ID}) { - if ($LOS) { + if ($reason == 1) { $target->{attack_failedLOS} = time; - } else { + } elsif ($reason == 0) { $target->{attack_failed} = time; } } $target->{dmgFromYou} = 0; # Hack | TODO: Fix me AI::dequeue() while (AI::inQueue("attack")); - message T("Can't reach or damage target, dropping target\n"), "ai_attack"; + message T("Dropping target\n"), "ai_attack"; if ($config{'teleportAuto_dropTarget'}) { message T("Teleport due to dropping attack target\n"); ai_useTeleport(1); @@ -399,6 +406,7 @@ sub main { $char->{pos_to}{y} = $char->{movetoattack_pos}{y}; $char->{time_move} = time; $char->{time_move_calc} = 0; + $char->{solution} = []; delete $char->{movetoattack_pos}; } } @@ -424,12 +432,15 @@ 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 $monsterPos = $target->{pos_to}; my $monsterDist = blockDistance($myPos, $monsterPos); - my $realMyPos = calcPosFromPathfinding($field, $char); - my $realMonsterPos = calcPosFromPathfinding($field, $target); + my $realMyPos = calcPosFromPathfinding($field, $char, $extra_time); + my $realMonsterPos = calcPosFromPathfinding($field, $target, $extra_time); my $realMonsterDist = blockDistance($realMyPos, $realMonsterPos); my $clientDist = getClientDist($realMyPos, $realMonsterPos); @@ -654,7 +665,7 @@ sub main { } if (!$args->{firstLoop} && $canAttack == 0 && $youHitTarget) { - debug TF("[%s] We were able to hit target even though it is out of range or LOS, accepting and continuing. (you %s (%d %d), target %s (%d %d) [(%d %d) -> (%d %d)], distance %d, maxDistance %d, dmgFromYou %d)\n", $canAttack_fail_string, $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; + debug TF("[%s] We were able to hit target even though it is out of range, accepting and continuing. (you %s (%d %d), target %s (%d %d) [(%d %d) -> (%d %d)], distance %d, maxDistance %d, dmgFromYou %d)\n", $canAttack_fail_string, $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}, $realMonsterDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; if ($clientDist > $args->{attackMethod}{maxDistance} && $clientDist <= ($args->{attackMethod}{maxDistance} + 1) && $args->{temporary_extra_range} == 0) { debug TF("[%s] Probably extra range provided by the server due to chasing, increasing range by 1.\n", $canAttack_fail_string), 'ai_attack'; $args->{temporary_extra_range} = 1; @@ -697,6 +708,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{attackCanSnipe}, $args->{attackMethod}{maxDistance}, $config{clientSight}); + if ($futurecanAttack) { + warning TF("[Attack] You 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", + $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'}, $char, $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 && @@ -852,4 +878,4 @@ sub main { Plugins::callHook('AI::Attack::main', {target => $target}) } -1; +1; \ No newline at end of file diff --git a/src/AI/CoreLogic.pm b/src/AI/CoreLogic.pm index 2ac761ada1..fb06acaa66 100644 --- a/src/AI/CoreLogic.pm +++ b/src/AI/CoreLogic.pm @@ -2192,30 +2192,31 @@ sub processLockMap { Plugins::callHook('AI/lockMap', \%args); unless ($args{'return'}) { my ($cell, $i); - eval { - my $lockField = new Field(name => $config{'lockMap'}, loadWeightMap => 0); - $cell = get_lockMap_cell($lockField); - }; - - if (caught('FileNotFoundException') || !defined $cell) { - error T("Invalid coordinates specified for lockMap, coordinates are unwalkable\n"); - $config{'lockMap'} = ''; - } else { - my $attackAuto = getAttackAutoModeForContext('routeToLock'); - my $attackOnRoute = (!defined $attackAuto || $attackAuto < 0) ? 0 : ($attackAuto >= 2 ? 2 : 1); - if (defined $cell->{x} || defined $cell->{y}) { - message TF("Calculating lockMap route to: %s(%s): %s, %s\n", $maps_lut{$config{'lockMap'}.'.rsw'}, $config{'lockMap'}, $cell->{x}, $cell->{y}), "route"; - } else { - message TF("Calculating lockMap route to: %s(%s)\n", $maps_lut{$config{'lockMap'}.'.rsw'}, $config{'lockMap'}), "route"; + if ($config{'lockMap_x'} || $config{'lockMap_y'}) { + eval { + my $lockField = new Field(name => $config{'lockMap'}, loadWeightMap => 0); + $cell = get_lockMap_cell($lockField); + }; + if (caught('FileNotFoundException') || !defined $cell) { + error T("Invalid coordinates specified for lockMap, coordinates are unwalkable\n"); + $config{'lockMap'} = ''; + return; } - ai_route( - $config{'lockMap'}, - $cell->{x}, - $cell->{y}, - attackOnRoute => $attackOnRoute, - isToLockMap => 1 - ); } + my $attackAuto = getAttackAutoModeForContext('routeToLock'); + my $attackOnRoute = (!defined $attackAuto || $attackAuto < 0) ? 0 : ($attackAuto >= 2 ? 2 : 1); + if (defined $cell->{x} || defined $cell->{y}) { + message TF("Calculating lockMap route to: %s(%s): %s, %s\n", $maps_lut{$config{'lockMap'}.'.rsw'}, $config{'lockMap'}, $cell->{x}, $cell->{y}), "route"; + } else { + message TF("Calculating lockMap route to: %s(%s)\n", $maps_lut{$config{'lockMap'}.'.rsw'}, $config{'lockMap'}), "route"; + } + ai_route( + $config{'lockMap'}, + $cell->{x}, + $cell->{y}, + attackOnRoute => $attackOnRoute, + isToLockMap => 1 + ); } } } diff --git a/src/Misc.pm b/src/Misc.pm index dbb503f11b..e989ec911b 100644 --- a/src/Misc.pm +++ b/src/Misc.pm @@ -474,37 +474,48 @@ sub configModify { Plugins::callHook('configModify', { key => $key, val => $val, + bulk => 0, additionalOptions => \%args }); - if (!$args{silent} && $key !~ /password/i) { - my $oldval = $config{$key}; - if (!defined $oldval) { - $oldval = "not set"; - } - - if ($config{$key} eq $val) { - if ($val) { - message TF("Config '%s' is already %s\n", $key, $val), "info"; - }else{ - message TF("Config '%s' is already *None*\n", $key), "info"; + my $silent = ($args{silent} || $key =~ /password/i) ? 1 : 0; + + if (!exists $config{$key}) { + return unless ($args{autoCreate}); + my $f; + if (open($f, ">>", Settings::getConfigFilename())) { + print $f "$key\n"; + close($f); + unless ($silent) { + message TF("Config '%s' autocreated\n", $key), "info"; + if (!defined $val) { + message TF("Config '%s' set to *None*\n", $key), "info"; + } else { + message TF("Config '%s' set to %s\n", $key, $val), "info"; + } } + } else { + error TF("Failed to autocreate config key '%s'\n", $key), "info"; return; } - + } elsif (!defined $config{$key}) { if (!defined $val) { - message TF("Config '%s' unset (was %s)\n", $key, $oldval), "info"; + message TF("Config '%s' is already *None*\n", $key), "info" unless ($silent); + return; } else { - message TF("Config '%s' set to %s (was %s)\n", $key, $val, $oldval), "info"; + message TF("Config '%s' set to %s (was *None*)\n", $key, $val), "info" unless ($silent); } - } - if ($args{autoCreate} && !exists $config{$key}) { - my $f; - if (open($f, ">>", Settings::getConfigFilename())) { - print $f "$key\n"; - close($f); + } else { + if (!defined $val) { + message TF("Config '%s' unset (was %s)\n", $key, $config{$key}), "info" unless ($silent); + } elsif ($config{$key} eq $val) { + message TF("Config '%s' is already %s\n", $key, $val), "info" unless ($silent); + return; + } else { + message TF("Config '%s' set to %s (was %s)\n", $key, $val, $config{$key}), "info" unless ($silent); } } + $config{$key} = $val; if (_isDynamicPortalConfigKey($key)) { applyDynamicPortalStates(); @@ -512,7 +523,10 @@ sub configModify { Settings::update_log_filenames() if $key =~ /^(username|char|server)$/o; saveConfigFile(); - Plugins::callHook('post_configModify'); + Plugins::callHook('post_configModify', { + key => $key, + bulk => 0 + }); } ## @@ -528,26 +542,49 @@ sub bulkConfigModify { my %create_keys; + my %changed_keys; foreach my $key (keys %{$r_hash}) { + my $val = $r_hash->{$key}; Plugins::callHook('configModify', { key => $key, - val => $r_hash->{$key}, - silent => $silent + val => $val, + silent => $silent, + bulk => 1 }); - $oldval = $config{$key}; + my $local_silent = ($silent || $key =~ /password/i) ? 1 : 0; if (!exists $config{$key}) { $create_keys{$key} = 1; - } - - $config{$key} = $r_hash->{$key}; + unless ($local_silent) { + message TF("Config '%s' autocreated\n", $key), "info"; + if (!defined $val) { + message TF("Config '%s' set to *None*\n", $key), "info"; + } else { + message TF("Config '%s' set to %s\n", $key, $val), "info"; + } + } + } elsif (!defined $config{$key}) { + if (!defined $val) { + message TF("Config '%s' is already *None*\n", $key), "info" unless ($local_silent); + next; + } else { + message TF("Config '%s' set to %s (was *None*)\n", $key, $val), "info" unless ($local_silent); + } - if ($key =~ /password/i) { - message TF("Config '%s' set to %s (was *not-displayed*)\n", $key, $r_hash->{$key}), "info" unless ($silent); } else { - message TF("Config '%s' set to %s (was %s)\n", $key, $r_hash->{$key}, $oldval), "info" unless ($silent); + if (!defined $val) { + message TF("Config '%s' unset (was %s)\n", $key, $config{$key}), "info" unless ($local_silent); + } elsif ($config{$key} eq $val) { + message TF("Config '%s' is already %s\n", $key, $val), "info" unless ($local_silent); + next; + } else { + message TF("Config '%s' set to %s (was %s)\n", $key, $val, $config{$key}), "info" unless ($local_silent); + } } + + $changed_keys{$key} = 1; + $config{$key} = $val; } if (scalar keys %create_keys > 0) { @@ -565,7 +602,9 @@ sub bulkConfigModify { } saveConfigFile(); - Plugins::callHook('post_configModify'); + Plugins::callHook('post_bulkConfigModify', { + keys => \%changed_keys + }); } ## @@ -2820,6 +2859,51 @@ sub manualMove { main::ai_route($field->baseName, $char->{pos_to}{x} + $dx, $char->{pos_to}{y} + $dy); } +sub getMeetingPositionCandidateSpots { + my ($realMyPos, $target_solution, $target_start_step, $max_path_dist, $attackMaxDistance, $min_destination_dist) = @_; + + $target_start_step = 0 if (!defined $target_start_step || $target_start_step < 0); + + my %seen; + foreach my $target_step ($target_start_step .. $#{$target_solution}) { + my $targetPos = $target_solution->[$target_step]; + next if !$targetPos; + + for (my $x = $targetPos->{x} - $attackMaxDistance; $x <= $targetPos->{x} + $attackMaxDistance; $x++) { + for (my $y = $targetPos->{y} - $attackMaxDistance; $y <= $targetPos->{y} + $attackMaxDistance; $y++) { + my %spot = ( + x => $x, + y => $y, + ); + my $key = "$x $y"; + next if exists $seen{$key}; + + + my $dist_to_target = blockDistance(\%spot, $targetPos); + next if $dist_to_target > $attackMaxDistance; + next if $dist_to_target < $min_destination_dist; + + my $dist_to_spot = blockDistance($realMyPos, \%spot); + next if $dist_to_spot > $max_path_dist; + + $seen{$key} = { + x => $x, + y => $y, + dist_to_spot => $dist_to_spot, + target_step => $target_step, + }; + } + } + } + + return sort { + $a->{dist_to_spot} <=> $b->{dist_to_spot} + || $a->{target_step} <=> $b->{target_step} + || $a->{x} <=> $b->{x} + || $a->{y} <=> $b->{y} + } values %seen; +} + ## # meetingPosition(actor, actorType, target_actor, attackMaxDistance, runFromTargetActive) # actor: current object. @@ -2832,16 +2916,25 @@ sub manualMove { sub meetingPosition { my ($actor, $actorType, $target, $attackMaxDistance, $runFromTargetActive) = @_; + my $start_time = time; + if ($attackMaxDistance < 1) { error "attackMaxDistance must be positive ($attackMaxDistance).\n"; return; } - my $extra_time_actor = $timeout{'meetingPosition_extra_time_actor'}{'timeout'} ? $timeout{'meetingPosition_extra_time_actor'}{'timeout'} : 0.2; - my $extra_time_target = $timeout{'meetingPosition_extra_time_target'}{'timeout'} ? $timeout{'meetingPosition_extra_time_target'}{'timeout'} : 0.2; + 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 $future_reachability_lookup_time = exists $timeout{'meetingPosition_future_reachability_lookup'}{'timeout'} ? $timeout{'meetingPosition_future_reachability_lookup'}{'timeout'} : 0.3; + $future_reachability_lookup_time = 0 unless (defined $future_reachability_lookup_time); + + my $max_leeway_time = exists $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'} ? $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'} : 0.3; + $max_leeway_time -= $extra_time; + $max_leeway_time = 0 if (!defined $max_leeway_time || $max_leeway_time < 0); my $mySpeed = ($actor->{walk_speed} || 0.12); - my $timeSinceActorMoved = time - $actor->{time_move} + $extra_time_actor; + my $timeSinceActorMoved = time - $actor->{time_move} + $extra_time; my $my_solution; my $timeActorFinishMove; @@ -2905,24 +2998,21 @@ sub meetingPosition { $timeActorFinishMove = calcTimeFromSolution($my_solution, $mySpeed); } - my $realMyPos; - # Actor has finished moving and is at PosTo - if ($timeSinceActorMoved >= $timeActorFinishMove) { - $realMyPos = $actor->{pos_to}; + my $realMyPos = calcPosFromPathfinding($field, $actor, $extra_time); - # Actor is currently moving - } else { - my $steps_walked = calcStepsWalkedFromTimeAndSolution($my_solution, $mySpeed, $timeSinceActorMoved); - $realMyPos = $my_solution->[$steps_walked]; - } + # Fall back to the server-reported destination if pathfinding could not infer a position. + $realMyPos = $actor->{pos_to} if (!$realMyPos && $actor->{pos_to}); - # Should never happen - unless ($field->isWalkable($realMyPos->{x}, $realMyPos->{y})) { - $realMyPos = $field->closestWalkableSpot($realMyPos, 1); + # Should never happen, but keep a nearby walkable fallback when possible. + if ($realMyPos && !$field->isWalkable($realMyPos->{x}, $realMyPos->{y})) { + my $closest_walkable = $field->closestWalkableSpot($realMyPos, 1); + $realMyPos = $closest_walkable if $closest_walkable; } + return unless $realMyPos && defined $realMyPos->{x} && defined $realMyPos->{y}; + my $targetSpeed = ($target->{walk_speed} || 0.12); - my $timeSinceTargetMoved = time - $target->{time_move} + $extra_time_target; + my $timeSinceTargetMoved = time - $target->{time_move} + $extra_time; my $target_solution = get_solution($field, $target->{pos}, $target->{pos_to}); @@ -2933,29 +3023,16 @@ sub meetingPosition { my $targetTotalSteps; my $targetCurrentStep; - my @target_pos_to_check; - # Target has finished moving if ($timeSinceTargetMoved >= $timeTargetFinishMove) { $realTargetPos = $target->{pos_to}; - $target_pos_to_check[0] = { - targetPosInStep => $realTargetPos - }; + $targetCurrentStep = 0; # Target is currently moving } else { $targetTotalSteps = $#{$target_solution}; $targetCurrentStep = calcStepsWalkedFromTimeAndSolution($target_solution, $targetSpeed, $timeSinceTargetMoved); $realTargetPos = $target_solution->[$targetCurrentStep]; - - my $steps_count = 0; - foreach my $currentStep ($targetCurrentStep..$targetTotalSteps) { - $target_pos_to_check[$steps_count] = { - targetPosInStep => $target_solution->[$currentStep] - }; - } continue { - $steps_count++; - } } my $master_moving; @@ -2965,7 +3042,7 @@ sub meetingPosition { my $masterSpeed; if ($masterPos) { $masterSpeed = ($master->{walk_speed} || 0.12); - $timeSinceMasterMoved = time - $master->{time_move} + $extra_time_actor; + $timeSinceMasterMoved = time - $master->{time_move} + $extra_time; $master_solution = get_solution($field, $master->{pos}, $master->{pos_to}); @@ -2996,110 +3073,209 @@ sub meetingPosition { } # Add 1 here to account for pos from solution so we don't have to do it multiple times later $max_path_dist += 1; - - my %allspots; - my @blocks = calcRectArea2($realMyPos->{x}, $realMyPos->{y}, $max_path_dist, 0); - foreach my $spot (@blocks) { - $allspots{$spot->{x}}{$spot->{y}} = 1; - } + + my @candidate_spots = getMeetingPositionCandidateSpots( + $realMyPos, + $target_solution, + $targetCurrentStep, + $max_path_dist, + $attackMaxDistance, + $min_destination_dist, + ); + my $allspots_count = scalar @candidate_spots; my %prohibitedSpots; foreach my $prohibited_actor (@$playersList, @$monstersList, @$npcsList, @$petsList, @$slavesList, @$elementalsList) { $prohibitedSpots{$prohibited_actor->{pos_to}{x}}{$prohibited_actor->{pos_to}{y}} = 1; } + my %prohibitedCells; + my %plugin_args = ( cells => \%prohibitedCells, field => $field, caller => 'meetingPosition' ); + Plugins::callHook('add_prohibitedCells' => \%plugin_args); + my $best_spot; my $best_targetPosInStep; my $best_dist_to_target; + my $best_dist_to_spot; + my $best_path_dist; my $best_time; + my $best_solution; + my %meeting_rejections; - foreach my $x_spot (sort keys %allspots) { - foreach my $y_spot (sort keys %{$allspots{$x_spot}}) { - my $spot; - $spot->{x} = $x_spot; - $spot->{y} = $y_spot; + require Task::Route; + my $solution; - next unless ($spot->{x} != $realMyPos->{x} || $spot->{y} != $realMyPos->{y}); + debug "[meetingPosition] before allspots. candidates=$allspots_count max_path_dist=$max_path_dist myPos=$realMyPos->{x} $realMyPos->{y} targetPos=$realTargetPos->{x} $realTargetPos->{y}\n", "ai_attack", 2; + foreach my $candidate (@candidate_spots) { + my $spot = { + x => $candidate->{x}, + y => $candidate->{y}, + }; - # Is this spot acceptable? + if ($spot->{x} == $realMyPos->{x} && $spot->{y} == $realMyPos->{y}) { + $meeting_rejections{same_as_self}++; + next; + } - # 1. It must be walkable. - next unless ($field->isWalkable($spot->{x}, $spot->{y})); - - # 1.2 It must not be occupied - next if (exists $prohibitedSpots{$spot->{x}} && exists $prohibitedSpots{$spot->{x}}{$spot->{y}}); + # Is this spot acceptable? - # 2. It must not be close to a portal. - next if (positionNearPortal($spot, $config{'attackMinPortalDistance'})); + # 1. It must be walkable. + unless ($field->isWalkable($spot->{x}, $spot->{y})) { + $meeting_rejections{not_walkable}++; + next; + } + + # 1.2 It must not be occupied + if (exists $prohibitedSpots{$spot->{x}} && exists $prohibitedSpots{$spot->{x}}{$spot->{y}}) { + $meeting_rejections{occupied}++; + next; + } - my $time_actor_to_get_to_spot; + # 1.3 It must not be inside plugin-provided prohibited cells. + if (exists $prohibitedCells{$spot->{x}} && exists $prohibitedCells{$spot->{x}}{$spot->{y}}) { + $meeting_rejections{prohibited_cell}++; + next; + } - my $solution = get_solution($field, $realMyPos, $spot); - - # 3. It must be reachable. - next if (scalar @{$solution} == 0); - - # 4. It must have at max $max_path_dist of route distance to it from our current position. - next if (scalar @{$solution} > $max_path_dist); + my $dist_to_spot = $candidate->{dist_to_spot}; - $time_actor_to_get_to_spot = calcTimeFromSolution($solution, $mySpeed); + if (defined $best_path_dist && $dist_to_spot >= $best_path_dist) { + $meeting_rejections{worse_than_best}++; + next; + } + + # 2. It must not be close to a portal. + if (positionNearPortal($spot, $config{'attackMinPortalDistance'})) { + $meeting_rejections{near_portal}++; + next; + } + @{$solution} = (); + unless (Task::Route->getRoute($solution, $field, $realMyPos, $spot, $config{'route_avoidWalls'}, 0, 0, 1, 1)) { + $meeting_rejections{route_failed}++; + next; + } - my $total_time = ($timeSinceTargetMoved+$time_actor_to_get_to_spot); - my $temp_targetCurrentStep = calcStepsWalkedFromTimeAndSolution($target_solution, $targetSpeed, $total_time); - # Position target would be at if it doesn't change route (and is not following us) - my $targetPosInStep = $target_solution->[$temp_targetCurrentStep]; + # 3. It must be reachable. + if (scalar @{$solution} == 0) { + $meeting_rejections{empty_solution}++; + next; + } + + my $path_dist = scalar @{$solution}; + + # 4. It must have at max $max_path_dist of route distance to it from our current position. + if ($path_dist > $max_path_dist) { + $meeting_rejections{path_too_long}++; + next; + } - # 5. It must not be the same position the target will be in - next unless ($spot->{x} != $targetPosInStep->{x} || $spot->{y} != $targetPosInStep->{y}); - - # 6. We must be able to attack the target from this spot - next unless (canAttack($field, $spot, $targetPosInStep, $attackCanSnipe, $attackMaxDistance, $config{clientSight}) == 1); - - # 7. It must not be too close to the target if we have runfromtarget set - # TODO: Maybe we should assume the target will keep following us after it reaches its destination and take that into consideration when runfromtarget is set - my $dist_to_target = blockDistance($spot, $targetPosInStep); - next unless ($dist_to_target >= $min_destination_dist); - - # 8. It must be within $followDistanceMax of MasterPos, if we have a master. - if ($realMasterPos) { - my $masterPosNow; - if ($master_moving) { - my $totalTime = $timeSinceMasterMoved + $time_actor_to_get_to_spot; - my $master_CurrentStep = calcStepsWalkedFromTimeAndSolution($master_solution, $masterSpeed, $totalTime); - $masterPosNow = $master_solution->[$master_CurrentStep]; - } else { - $masterPosNow = $realMasterPos; - } - next unless ($spot->{x} != $masterPosNow->{x} || $spot->{y} != $masterPosNow->{y}); - next unless (blockDistance($spot, $masterPosNow) <= $followDistanceMax); - next unless (blockDistance($targetPosInStep, $masterPosNow) <= $followDistanceMax); + my $time_actor_to_get_to_spot = calcTimeFromSolution($solution, $mySpeed); + + my $total_time = ($timeSinceTargetMoved+$time_actor_to_get_to_spot); + + my $temp_targetCurrentStep = calcStepsWalkedFromTimeAndSolution($target_solution, $targetSpeed, $total_time); + # Position target would be at if it doesn't change route (and is not following us) + my $targetPosInStep = $target_solution->[$temp_targetCurrentStep]; + + # 5. It must not be the same position the target will be in + if ($spot->{x} == $targetPosInStep->{x} && $spot->{y} == $targetPosInStep->{y}) { + $meeting_rejections{same_as_target}++; + next; + } + + my $leeway = 0; + # 6. We must be able to attack the target from this spot + if (canAttack($field, $spot, $targetPosInStep, $attackCanSnipe, $attackMaxDistance, $config{clientSight}) != 1) { + my $leeway_targetCurrentStep = calcStepsWalkedFromTimeAndSolution($target_solution, $targetSpeed, ($total_time + $max_leeway_time)); + my $leeway_targetPosInStep = $target_solution->[$leeway_targetCurrentStep]; + if (canAttack($field, $spot, $leeway_targetPosInStep, $attackCanSnipe, $attackMaxDistance, $config{clientSight}) != 1) { + $meeting_rejections{cannot_attack}++; + next; + } else { + $leeway = $max_leeway_time; } + } - # 8. We must be able to get to the spot before our target - # TODO: Fix me. The target does not need to get to the spot, but to at least 2 cells away to be able to attack us, so take that into account - if ($runFromTargetActive) { - my $time_target_to_get_to_spot = calcTimeFromPathfinding($field, $realTargetPos, $spot, $targetSpeed); - if ($time_actor_to_get_to_spot > $time_target_to_get_to_spot) { - next; - } + if ($future_reachability_lookup_time) { + my $future_targetCurrentStep = calcStepsWalkedFromTimeAndSolution($target_solution, $targetSpeed, ($total_time + $leeway + $future_reachability_lookup_time)); + my $future_targetPosInStep = $target_solution->[$future_targetCurrentStep]; + + # 6.1. We must be able to attack the target from this spot in the near future (exclude very thin attack window) + if (canAttack($field, $spot, $future_targetPosInStep, $attackCanSnipe, $attackMaxDistance, $config{clientSight}) != 1) { + $meeting_rejections{cannot_attack_future}++; + next; + } + } + + # 7. It must not be too close to the target if we have runfromtarget set + # TODO: Maybe we should assume the target will keep following us after it reaches its destination and take that into consideration when runfromtarget is set + my $dist_to_target = blockDistance($spot, $targetPosInStep); + if ($dist_to_target < $min_destination_dist) { + $meeting_rejections{too_close_to_target}++; + next; + } + + # 8. It must be within $followDistanceMax of MasterPos, if we have a master. + if ($realMasterPos) { + my $masterPosNow; + if ($master_moving) { + my $totalTime = $timeSinceMasterMoved + $time_actor_to_get_to_spot; + my $master_CurrentStep = calcStepsWalkedFromTimeAndSolution($master_solution, $masterSpeed, $totalTime); + $masterPosNow = $master_solution->[$master_CurrentStep]; + } else { + $masterPosNow = $realMasterPos; + } + if ($spot->{x} == $masterPosNow->{x} && $spot->{y} == $masterPosNow->{y}) { + $meeting_rejections{same_as_master}++; + next; + } + if (blockDistance($spot, $masterPosNow) > $followDistanceMax) { + $meeting_rejections{too_far_from_master}++; + next; + } + if (blockDistance($targetPosInStep, $masterPosNow) > $followDistanceMax) { + $meeting_rejections{target_too_far_from_master}++; + next; } + } - # We then choose the spot which takes the least amount of time to reach - # TODO: Maybe this is not the best idea when runfromtarget is set - if (!defined($best_time) || $time_actor_to_get_to_spot < $best_time) { - $best_time = $time_actor_to_get_to_spot; - $best_spot = $spot; - $best_targetPosInStep = $targetPosInStep; - $best_dist_to_target = $dist_to_target; + # 8. We must be able to get to the spot before our target + # TODO: Fix me. The target does not need to get to the spot, but to at least 2 cells away to be able to attack us, so take that into account + if ($runFromTargetActive) { + my $time_target_to_get_to_spot = calcTimeFromPathfinding($field, $realTargetPos, $spot, $targetSpeed); + if ($time_actor_to_get_to_spot > $time_target_to_get_to_spot) { + $meeting_rejections{target_gets_there_first}++; + next; } } + + # We then choose the spot which takes the least amount of time to reach + # TODO: Maybe this is not the best idea when runfromtarget is set + if (!defined($best_time) || $time_actor_to_get_to_spot < $best_time) { + $best_time = $time_actor_to_get_to_spot; + $best_spot = $spot; + $best_dist_to_spot = $dist_to_spot; + $best_path_dist = $path_dist; + $best_targetPosInStep = $targetPosInStep; + $best_dist_to_target = $dist_to_target; + $best_solution = $solution; + } } + my $end_time = time; + + my $elapsed = $end_time - $start_time; + debug "[meetingPosition] Elapsed time $elapsed\n", "ai_attack", 2; + + debug "[meetingPosition] Rejections: " . join(', ', map { $_ . '=' . $meeting_rejections{$_} } sort keys %meeting_rejections) . "\n", "ai_attack", 2; if (defined $best_spot) { - debug "[meetingPosition] Best spot is $best_spot->{x} $best_spot->{y}, mob will be at $best_targetPosInStep->{x} $best_targetPosInStep->{y}, dist $best_dist_to_target, it will take $best_time seconds to get there.\n"; + debug "[meetingPosition] Best spot is $best_spot->{x} $best_spot->{y}, mob will be at $best_targetPosInStep->{x} $best_targetPosInStep->{y}, dist $best_dist_to_target, it will take $best_time seconds to get there.\n", "ai_attack", 1; + debug "[meetingPosition] Solution: ". join(' >> ', map { "$_->{x} $_->{y}" } @{$best_solution}) ."\n", "ai_attack", 3; return $best_spot; } + + debug "[meetingPosition] No valid spot found.\n", "ai_attack", 1; } sub objectAdded { @@ -4004,14 +4180,23 @@ sub getBestTarget { my ($highestPri, $smallestDist, $bestTarget); # First of all we check monsters in LOS, then the rest of monsters + + my %plugin_args; + $plugin_args{possibleTargets} = $possibleTargets; + $plugin_args{attackCheckLOS} = $attackCheckLOS; + $plugin_args{attackCanSnipe} = $attackCanSnipe; + $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); + next if (positionNearPlayer($pos, $playerDist) || positionNearPortal($pos, $portalDist) ); + my $control = mon_control($monster->{name},$monster->{nameID}); if (defined $control) { next if ( ($control->{attack_auto} == -1) @@ -4023,15 +4208,7 @@ sub getBestTarget { || ($control->{attack_auto} == 0 && !($monster->{dmgToYou} || $monster->{missedYou})) ); } - - my %plugin_args; - $plugin_args{target} = $monster; - $plugin_args{control} = $control; - $plugin_args{attackCheckLOS} = $attackCheckLOS; - $plugin_args{attackCanSnipe} = $attackCanSnipe; - $plugin_args{return} = 0; - Plugins::callHook('getBestTarget' => \%plugin_args); - next if ($plugin_args{return}); + next if (_targetWillLeaveClientSightSoon($char, $monster)); if (!$field->checkLOS($myPos, $pos, $attackCanSnipe)) { @@ -6367,8 +6544,15 @@ sub get_lockMap_cell { my $cell; my $i = 500; - my $width = $field->width; - my $height = $field->height; + my $width = $lockField->width; + my $height = $lockField->height; + + my $max_x = $width -1; + my $max_y = $height -1; + + my %dropDestinationCells; + my %plugin_args = ( cells => \%dropDestinationCells, field => $lockField, caller => 'get_lockMap_cell' ); + Plugins::callHook('add_dropDestinationCells' => \%plugin_args); do { if ($config{'lockMap_x'} ne '') { @@ -6383,7 +6567,7 @@ sub get_lockMap_cell { } else { $cell->{y} = int(rand($height)); } - } while (--$i && (!$field->isWalkable($cell->{x}, $cell->{y}) || $cell->{x} <= 0 || $cell->{y} <= 0 || $cell->{x} >= $width || $cell->{y} >= $height)); + } while (--$i && (!$lockField->isWalkable($cell->{x}, $cell->{y}) || $cell->{x} <= 0 || $cell->{y} <= 0 || $cell->{x} >= $max_x || $cell->{y} >= $max_y || (exists $dropDestinationCells{$cell->{x}} && exists $dropDestinationCells{$cell->{x}}{$cell->{y}}))); return undef if (!$i); return $cell; diff --git a/src/Network/Receive.pm b/src/Network/Receive.pm index 59ef6497e3..fddab89635 100644 --- a/src/Network/Receive.pm +++ b/src/Network/Receive.pm @@ -1297,7 +1297,6 @@ sub map_loaded { $char->{time_move} = time; $char->{time_move_calc} = 0; $char->{solution} = []; - push(@{$char->{solution}}, { x => $char->{pos}{x}, y => $char->{pos}{y} }); # set initial status from data received from the char server (seems needed on eA, dunno about kRO)} if ($masterServer->{private}){ setStatus($char, $char->{opt1}, $char->{opt2}, $char->{option}); } @@ -5557,6 +5556,7 @@ sub character_moves { my $my_solution = get_solution($field, $char->{pos}, $char->{pos_to}); my $time = calcTimeFromSolution($my_solution, $speed); $char->{time_move} = time; + #$char->{time_move_server_tick} = unpack('V', $args->{move_start_time}); $char->{time_move_calc} = $time; $char->{solution} = $my_solution; @@ -7306,7 +7306,6 @@ sub map_change { $char->{time_move} = time; $char->{time_move_calc} = 0; $char->{solution} = []; - push(@{$char->{solution}}, { x => $char->{pos}{x}, y => $char->{pos}{y} }); message TF("Map Change: %s (%s, %s)\n", $args->{map}, $char->{pos}{x}, $char->{pos}{y}), "connection"; if ($net->version == 1) { ai_clientSuspend(0, $timeout{'ai_clientSuspend'}{'timeout'}); @@ -7361,7 +7360,6 @@ sub map_changed { $char->{time_move} = time; $char->{time_move_calc} = 0; $char->{solution} = []; - push(@{$char->{solution}}, { x => $char->{pos}{x}, y => $char->{pos}{y} }); undef $conState_tries; main::initMapChangeVars(); diff --git a/src/Network/Receive/ServerType0.pm b/src/Network/Receive/ServerType0.pm index a45032ef79..1034682466 100644 --- a/src/Network/Receive/ServerType0.pm +++ b/src/Network/Receive/ServerType0.pm @@ -1409,7 +1409,6 @@ sub skill_used_no_damage { $char->{time_move} = time; $char->{time_move_calc} = 0; $char->{solution} = []; - push(@{$char->{solution}}, { x => $char->{pos}{x}, y => $char->{pos}{y} }); } # Resolve source and target names diff --git a/src/Network/Receive/kRO/Sakexe_0.pm b/src/Network/Receive/kRO/Sakexe_0.pm index 09658fae16..c66d482fc6 100644 --- a/src/Network/Receive/kRO/Sakexe_0.pm +++ b/src/Network/Receive/kRO/Sakexe_0.pm @@ -1384,7 +1384,6 @@ sub skill_used_no_damage { $char->{time_move} = time; $char->{time_move_calc} = 0; $char->{solution} = []; - push(@{$char->{solution}}, { x => $char->{pos}{x}, y => $char->{pos}{y} }); } # Resolve source and target names diff --git a/src/Task/Route.pm b/src/Task/Route.pm index 648837bfe5..9cc8ec9d0e 100644 --- a/src/Task/Route.pm +++ b/src/Task/Route.pm @@ -35,7 +35,7 @@ use Network; use Field; use Translation qw(T TF); use Misc; -use Utils qw(timeOut adjustedBlockDistance distance blockDistance calcPosFromPathfinding existsInList); +use Utils qw(timeOut adjustedBlockDistance distance blockDistance calcPosFromPathfinding existsInList getLimits get_client_solution); use Utils::Exceptions; use Utils::Set; use Utils::PathFinding; @@ -45,7 +45,9 @@ use constant { NOT_INITIALIZED => 1, CALCULATE_ROUTE => 2, ROUTE_SOLUTION_READY => 3, - WALK_ROUTE_SOLUTION => 4 + WALK_ROUTE_SOLUTION => 4, + ROUTE_CLIENT_PATH_MAX_DEVIATION => 4, + ROUTE_CLIENT_PATH_RESET_TRIM_STEPS => 4 }; # Error code constants. @@ -85,6 +87,13 @@ use enum qw( # - pyDistFromGoal - Same as distFromGoal, but this allows you to specify the # Pythagorian distance instead of block distance. # - avoidWalls - Whether to avoid walls. The default is yes. +# - attackOnRoute - Controls how much normal route-time AI stays enabled while +# this route is active: +# 0 = do not auto-attack while routing; +# 1 = allow route-time combat checks, but still block +# route-triggered sell/storage automation; +# 2 = full "normal route" behavior, including route-time +# combat and route-compatible sell/storage checks. # - notifyUponArrival - Whether to print a message when we've reached the destination. # The default is no. # `l` @@ -125,20 +134,17 @@ sub new { $self->{dest}{pos}{x} = $args{x}; $self->{dest}{pos}{y} = $args{y}; if ($config{$self->{actor}{configPrefix}.'route_avoidWalls'}) { - if (!defined $self->{avoidWalls}) { - $self->{avoidWalls} = 1; - } + $self->{avoidWalls} = 1 if !defined $self->{avoidWalls}; } else { - $self->{avoidWalls} = 0; + $self->{avoidWalls} = 0 if !defined $self->{avoidWalls}; } if ($config{$self->{actor}{configPrefix}.'route_randomFactor'}) { - if (!defined $self->{randomFactor}) { - $self->{randomFactor} = $config{$self->{actor}{configPrefix}.'route_randomFactor'}; - } + $self->{randomFactor} = $config{$self->{actor}{configPrefix}.'route_randomFactor'} if (!defined $self->{randomFactor}); } else { - $self->{randomFactor} = 0; + $self->{randomFactor} = 0 if (!defined $self->{randomFactor}); } + if (!defined $self->{useManhattan}) { $self->{useManhattan} = 0; } @@ -155,6 +161,7 @@ sub new { my @holder = ($self); Scalar::Util::weaken($holder[0]); $self->{mapChangedHook} = Plugins::addHook('Network::Receive::map_changed', \&mapChanged, \@holder); + $self->{routeRepathHook} = Plugins::addHook('routeRepath', \&routeRepath, \@holder); return $self; } @@ -162,9 +169,19 @@ sub new { sub DESTROY { my ($self) = @_; Plugins::delHook($self->{mapChangedHook}) if $self->{mapChangedHook}; + Plugins::delHook($self->{routeRepathHook}) if $self->{routeRepathHook}; $self->SUPER::DESTROY(); } +sub routeRepath { + my (undef, $args, $holder) = @_; + my $self = $holder->[0]; + return unless $self; + + debug "[routeRepath] Repathing after hook routeRepath.\n", "route"; + $self->resetRoute; +} + ## # Hash* $Task_Route->destCoords() # @@ -221,13 +238,18 @@ sub iterate { undef $self->{sentTeleport}; undef $self->{mapChanged}; $self->resetRoute(); + $self->iterate(); + return; } } elsif ($self->{stage} == CALCULATE_ROUTE) { my $pos = $self->{actor}{pos}; my $pos_to = $self->{actor}{pos_to}; - - my $calc_pos = calcPosFromPathfinding($field, $self->{actor}); + + 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 $calc_pos = calcPosFromPathfinding($field, $self->{actor}, $extra_time); my $walk = 1; if ($config{route_teleport} == 2 @@ -284,14 +306,12 @@ sub iterate { debug "Route $self->{actor}: Current position and destination are the same.\n", "route"; $self->setDone(); - } elsif ($self->getRoute($self->{solution}, $self->{dest}{map}, $calc_pos, $self->{dest}{pos}, $self->{avoidWalls}, $self->{randomFactor}, $self->{useManhattan}, 1)) { + } elsif ($self->getRoute($self->{solution}, $self->{dest}{map}, $calc_pos, $self->{dest}{pos}, $self->{avoidWalls}, $self->{randomFactor}, $self->{useManhattan}, 1, 0)) { $self->{stage} = ROUTE_SOLUTION_READY; @{$self->{last_pos}}{qw(x y)} = @{$calc_pos}{qw(x y)}; @{$self->{last_pos_to}}{qw(x y)} = @{$pos_to}{qw(x y)}; $self->{start} = 1; - $self->{confirmed_correct_vector} = 0; - if ($self->{pyDistFromGoal} || $self->{distFromGoal}) { $self->{anyDistFromGoal} = 1; @@ -353,10 +373,8 @@ sub iterate { #undef $self->{last_pos}; #undef $self->{last_pos_to}; #undef $self->{start}; - #undef $self->{confirmed_correct_vector}; - undef $self->{last_best_pos_step}; - undef $self->{last_best_pos_to_step}; undef $self->{next_pos}; + undef $self->{current_move_step_index}; undef $self->{time_step}; $self->{stage} = WALK_ROUTE_SOLUTION; @@ -384,8 +402,11 @@ sub iterate { # $actor->{pos_to} is the position the character moved TO in the last move packet received @{$current_pos_to}{qw(x y)} = @{$self->{actor}{pos_to}}{qw(x y)}; + + 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); - $current_calc_pos = calcPosFromPathfinding($field, $self->{actor}); + $current_calc_pos = calcPosFromPathfinding($field, $self->{actor}, $extra_time); if ($current_calc_pos->{x} == $solution->[$#{$solution}]{x} && $current_calc_pos->{y} == $solution->[$#{$solution}]{y}) { # Actor position is the destination; we've arrived at the destination @@ -399,109 +420,21 @@ sub iterate { $self->setDone(); return; - } else { - # Failsafe - my $lookahead_failsafe_max = $self->{step_index} + 1; - my $lookahead_failsafe_count; - - # This looks ahead in the solution array and finds the closest position in it to our current {pos}, then it looks $lookahead_failsafe_max further, just to be sure - - $lookahead_failsafe_count = 0; - my $best_index; - my $best_dist; - - foreach my $step_i (0..$#{$solution}) { - my $step = $solution->[$step_i]; - if ($step->{x} == $current_calc_pos->{x} && $step->{y} == $current_calc_pos->{y}) { - $best_index = $step_i; - $best_dist = 0; - last; - } - my $dist = adjustedBlockDistance($current_calc_pos, $step); - if (!defined $best_dist || $best_dist > $dist) { - $best_index = $step_i; - $best_dist = $dist; - $lookahead_failsafe_count = 0; - } else { - $lookahead_failsafe_count++; - } - if ($lookahead_failsafe_count == $lookahead_failsafe_max) { - last; - } - } - my $best_pos_step = $best_index; - - # This does the same, but with {pos_to} - undef $best_index; - undef $best_dist; - foreach my $step_i (0..$#{$solution}) { - my $step = $solution->[$step_i]; - if ($step->{x} == $current_pos_to->{x} && $step->{y} == $current_pos_to->{y}) { - $best_index = $step_i; - $best_dist = 0; - last; - } - my $dist = adjustedBlockDistance($current_pos_to, $step); - if (!defined $best_dist || $best_dist > $dist) { - $best_index = $step_i; - $best_dist = $dist; - $lookahead_failsafe_count = 0; - } else { - $lookahead_failsafe_count++; - } - if ($lookahead_failsafe_count == $lookahead_failsafe_max) { - last; - } - } - my $best_pos_to_step = $best_index; - - # Here there may be the need to check if 'pos' has changed yet best_pos_step is still the same, creating a lag in the movement - - # Last change in pos and pos_to put us in a walk path oposite to the desired one - # TODO: This confirmed_correct_vector could probably be removed - if ($best_pos_step > $best_pos_to_step) { - if ($self->{confirmed_correct_vector}) { - debug "Route $self->{actor} - movement interrupted: reset route (last change in pos and pos_to put us in a walk path oposite to the desired one)\n", "route"; - $self->{solution} = []; - $self->{stage} = CALCULATE_ROUTE; - return; - } - } elsif (!$self->{confirmed_correct_vector}) { - debug "Route $self->{actor} - movement vector confirmed.\n", "route"; - $self->{confirmed_correct_vector} = 1; - } + } - # Last move was to the cell we are already at, lag?, buggy code? + if (@{$solution}) { + my ($anchor_index, $anchor_dist) = _bestSolutionAnchorIndex($solution, $#{$solution}, $current_calc_pos); if ($self->{start}) { debug "Route $self->{actor} - not trimming down solution (" . @{$solution} . ") because we have not moved yet.\n", "route", 2; - - } elsif ($best_pos_step == 0) { - debug "Route $self->{actor} - not trimming down solution (" . @{$solution} . ") because best_pos_step is 0.\n", "route", 2; - + } elsif ($anchor_index == 0) { + debug "Route $self->{actor} - not trimming down solution (" . @{$solution} . ") because calc anchor is 0 (dist $anchor_dist).\n", "route", 2; } else { - # Should we trimm only the known walk ones ($best_pos_step) or the known + the guessed (calcStepsWalkedFromTimeAndRoute)? Default was both. - - # Currently testing delete up to known + the guessed - debug "Route $self->{actor} - trimming down solution (" . @{$solution} . ") by ".($best_pos_step)." steps\n", "route"; - - splice(@{$solution}, 0, $best_pos_step); + debug "Route $self->{actor} - trimming down solution (" . @{$solution} . ") by $anchor_index calc-anchor steps\n", "route"; + splice(@{$solution}, 0, $anchor_index); } - - $self->{last_best_pos_step} = $best_pos_step; - $self->{last_best_pos_to_step} = $best_pos_to_step; - } - - my $pos_changed; - if ($self->{last_current_calc_pos}{x} == $current_calc_pos->{x} && $self->{last_current_calc_pos}{y} == $current_calc_pos->{y}) { - $pos_changed = 0; - } else { - $pos_changed = 1; } my $stepsleft = @{$solution}; - - $self->{lastStep} = 0; - if ($stepsleft == 0) { # No more points to cover; we've arrived at the destination if ($self->{notifyUponArrival}) { @@ -512,8 +445,14 @@ sub iterate { Plugins::callHook('route', {status => 'success'}); $self->setDone(); + return; + } + + my $pos_changed = ($self->{last_current_calc_pos}{x} == $current_calc_pos->{x} && $self->{last_current_calc_pos}{y} == $current_calc_pos->{y}) ? 0 : 1; + + $self->{lastStep} = 0; - } elsif ($stepsleft == 2 && isCellOccupied($solution->[-1]) && !$self->{meetingSubRoute}) { + if ($stepsleft == 2 && isCellOccupied($solution->[-1]) && !$self->{meetingSubRoute}) { # 2 more steps to cover (current position and the destination) debug "Stoping 1 cell away from destination because there is an obstacle in it.\n", "route"; if ($self->{notifyUponArrival}) { @@ -559,6 +498,8 @@ sub iterate { # $self->{route_out_time} = time; $self->resetRoute(); + $self->iterate(); + return; } } elsif (timeOut($self->{route_out_time}, 3)) { # Because of attack monster, get item or something else we are out of our route for a long time @@ -566,6 +507,8 @@ sub iterate { debug "We are out of our route for a long time, recalculating...\n", "route"; $self->{route_out_time} = time; $self->resetRoute(); + $self->iterate(); + return; } elsif (!$self->{start} && $pos_changed == 0 && defined $self->{time_step} && timeOut($self->{time_step}, $timeout{ai_route_unstuck}{timeout})) { # We tried to move for 3 seconds, but we are still on the same spot, decrease step size. # However, if $self->{step_index} was already 0, then that means we were almost at the destination (only 1 more step is needed). @@ -614,41 +557,108 @@ sub iterate { } } - # If there are less steps to cover than the step size move to the last step (the destination). - if ($self->{step_index} >= $stepsleft) { - $self->{step_index} = $stepsleft - 1; + # Keep step_index as the persistent safeguard/unstuck step size. + # Hook-driven route_step changes should only affect the move we send now. + my $move_step_index = $self->{step_index}; + if ($move_step_index >= $stepsleft) { + $move_step_index = $stepsleft - 1; $self->{lastStep} = 1; } - # Here maybe we should also use pos_to (in the form of best_pos_to_step) to decide the next step index, as it can make the routing way more responsive + my $requested_move_step_index = $move_step_index; + + while ($move_step_index > 0) { + my $candidate_pos = $solution->[$move_step_index]; + + if (!$field->canMove($current_calc_pos, $candidate_pos)) { + return if _trimMoveStepOrReset($self, $requested_move_step_index, \$move_step_index, + "because we cannot move from ($current_calc_pos->{x} $current_calc_pos->{y}) to ($candidate_pos->{x}, $candidate_pos->{y})"); + next; + } + + last if ($self->{lastStep}); + + my $client_solution = get_client_solution($field, $current_calc_pos, $candidate_pos); + if (!$client_solution || !@{$client_solution}) { + return if _trimMoveStepOrReset($self, $requested_move_step_index, \$move_step_index, + "because client solution from ($current_calc_pos->{x} $current_calc_pos->{y}) to ($candidate_pos->{x}, $candidate_pos->{y}) could not be simulated"); + next; + } + + my @trusted_slice = @{$solution}[0 .. $move_step_index]; + my $max_deviation = _maxPathDeviation($client_solution, \@trusted_slice); + #debug "[move_step_index $move_step_index] Route estimated deviation: $max_deviation\n", "route"; + + if ($max_deviation > ROUTE_CLIENT_PATH_MAX_DEVIATION) { + return if _trimMoveStepOrReset($self, $requested_move_step_index, \$move_step_index, + "because simulated client path would drift too far from trusted route (max deviation $max_deviation)"); + next; + } + + last; + } + + # Give plugins a chance to shrink the local move packet distance if the + # client-side path to the lookahead cell would cut through danger. + my %routeStepHookArgs = ( + task => $self, + solution => $solution, + current_pos => $current_pos, + current_pos_to => $current_pos_to, + current_calc_pos => $current_calc_pos, + stepsleft => $stepsleft, + config_route_step => $config{$self->{actor}{configPrefix}.'route_step'}, + route_step => $move_step_index, + ); + Plugins::callHook('route_step', \%routeStepHookArgs); + + if ($self->{resetRoute}) { + $self->resetRoute(); + delete $self->{resetRoute}; + $self->iterate(); + return; + } + + if (defined $routeStepHookArgs{route_step} && $routeStepHookArgs{route_step} != $move_step_index) { + $move_step_index = $routeStepHookArgs{route_step}; + } - if ($self->{anyDistFromGoal}) { - my $step = $solution->[$self->{step_index}]; + my $step = $solution->[$move_step_index]; # We are close enough to the destination if (exists $step->{closeToEnd} && $step->{closeToEnd}) { - my $current_i = $self->{step_index}; + my $current_i = $move_step_index; while (1) { last if ($current_i == 0); last if ($solution->[($current_i-1)]{closeToEnd} == 0); $current_i--; } - $self->{step_index} = $current_i; + $move_step_index = $current_i; } } - @{$self->{next_pos}}{qw(x y)} = @{$solution->[$self->{step_index}]}{qw(x y)}; + + $move_step_index = 0 if $move_step_index < 0; + + if ($move_step_index >= $stepsleft) { + $move_step_index = $stepsleft - 1; + $self->{lastStep} = 1; + } + + $self->{current_move_step_index} = $move_step_index; + @{$self->{next_pos}}{qw(x y)} = @{$solution->[$move_step_index]}{qw(x y)}; + # But first, check whether the distance of the next point isn't abnormally large. # If it is, then we've moved to an unexpected place. This could be caused by auto-attack, for example. - # TODO: This should be calcDistFromPath or something like that my %nextPos = (x => $self->{next_pos}{x}, y => $self->{next_pos}{y}); - if (blockDistance(\%nextPos, $current_calc_pos) > 17) { + + if (!$field->canMove(\%nextPos, $current_calc_pos)) { debug "Route $self->{actor} - movement interrupted: reset route (the distance of the next point is abnormally large ($current_calc_pos->{x} $current_calc_pos->{y} -> $nextPos{x} $nextPos{y}))\n", "route"; - $self->{solution} = []; - $self->{stage} = CALCULATE_ROUTE; + $self->resetRoute(); + $self->iterate(); + return; + } - } else { - if ($self->{targetNpcPos}) { my $found = 0; foreach my $actor (@{$npcsList->getItems()}) { @@ -728,11 +738,21 @@ sub iterate { @{$self->{last_pos_to}}{qw(x y)} = @{$current_pos_to}{qw(x y)}; @{$self->{last_current_calc_pos}}{qw(x y)} = @{$current_calc_pos}{qw(x y)}; - debug "Route $self->{actor} - next step moving to ($self->{next_pos}{x}, $self->{next_pos}{y}), index $self->{step_index}, $stepsleft steps left\n", "route"; + debug "Route $self->{actor} at ($current_calc_pos->{x} $current_calc_pos->{y}) - next step moving to ($self->{next_pos}{x}, $self->{next_pos}{y}), index $move_step_index, $stepsleft steps left\n", "route"; + my %routeSetMoveHookArgs = ( + task => $self, + actor => $self->{actor}, + current_pos => $current_pos, + current_pos_to => $current_pos_to, + current_calc_pos => $current_calc_pos, + next_pos => $self->{next_pos}, + move_step_index => $move_step_index, + stepsleft => $stepsleft, + ); + Plugins::callHook('route_setMove', \%routeSetMoveHookArgs); $self->setMove(); } - } $self->{route_out_time} = time; } else { # This statement should never be reached. @@ -767,14 +787,85 @@ sub resetRoute { $self->{stage} = CALCULATE_ROUTE; } +sub _bestSolutionAnchorIndex { + my ($solution, $max_index, $pos) = @_; + + $max_index = $#{$solution} if $max_index > $#{$solution}; + my ($best_index, $best_dist) = (0, undef); + + for my $i (0 .. $max_index) { + my $dist = adjustedBlockDistance($solution->[$i], $pos); + if (!defined $best_dist || $dist < $best_dist) { + $best_dist = $dist; + $best_index = $i; + last if $best_dist == 0; + } + } + + #my $displacedist = adjustedBlockDistance($solution->[0], $pos); + #debug "[Route trimm] calc_pos is ($pos->{x} $pos->{y}) >> best_index is [$best_index]\n", "route"; + #debug "[Route trimm] Solution[0] is ($solution->[0]{x}, $solution->[0]{y}) >> dist $displacedist\n", "route"; + #debug "[Route trimm] Solution[i] is ($solution->[$best_index]{x}, $solution->[$best_index]{y}) >> dist $best_dist\n", "route"; + + debug "[Route] Current drift is [$best_dist] Sol [$solution->[$best_index]{x} $solution->[$best_index]{y}] x [$pos->{x} $pos->{y}] Pos\n", "route"; + + return ($best_index, $best_dist || 0); +} + +sub _maxPathDeviation { + my ($path_a, $path_b) = @_; + return 0 if !$path_a || !$path_b || !@{$path_a} || !@{$path_b}; + + my $max_index_a = $#{$path_a}; + my $max_index_b = $#{$path_b}; + my $smaller = $max_index_a > $max_index_b ? $max_index_b : $max_index_a; + + my $max_dev = 0; + + foreach my $index (0..$smaller) { + my $cell_a = $path_a->[$index]; + my $cell_b = $path_b->[$index]; + my $dist = blockDistance($cell_a, $cell_b); + if ($dist > $max_dev) { + $max_dev = $dist; + } + } + + return $max_dev; +} + +sub _resetRouteForMoveSelection { + my ($self, $reason) = @_; + debug "Route $self->{actor} - movement interrupted: reset route ($reason)\n", "route"; + $self->resetRoute(); + $self->iterate(); + return 1; +} + +sub _trimMoveStepOrReset { + my ($self, $requested_move_step_index, $move_step_index_ref, $reason) = @_; + + debug "Route $self->{actor} - trimming down move_step_index from $$move_step_index_ref to (".($$move_step_index_ref - 1).") $reason\n", "route"; + $$move_step_index_ref--; + + if (($requested_move_step_index - $$move_step_index_ref) >= ROUTE_CLIENT_PATH_RESET_TRIM_STEPS) { + return _resetRouteForMoveSelection($self, "had to trim down $requested_move_step_index to $$move_step_index_ref while selecting next move"); + } + + return 0; +} + ## -# boolean Task::Route->getRoute(Array* solution, Field field, Hash* start, Hash* dest, [boolean avoidWalls = true], [boolean self_call = false]) +# boolean Task::Route->getRoute(Array* solution, Field field, Hash* start, Hash* dest, [boolean avoidWalls = true], [int randomFactor = 0], [boolean useManhattan = false], [boolean liveRoute = false], [boolean addLimits = false]) # $solution: The route solution will be stored in here. # field: the field on which a route must be calculated. # start: The is the start coordinate. # dest: The destination coordinate. # avoidWalls: 0 if you don't want to avoid walls on route. -# self_call: 1 if it was called from inside this module. +# randomFactor: additional random penalty added to each expanded neighbor. +# useManhattan: 1 to use the client's Manhattan heuristic instead of the diagonal heuristic. +# liveRoute: 1 if it is a live calculation (intends to be walked in the live $field) +# addLimits: 1 if the route search bounds should be narrowed around start and destination. # Returns: 1 if the calculation succeeded, 0 if not. # # Calculate how to walk from $start to $dest on field $field, or check whether there @@ -786,7 +877,7 @@ sub resetRoute { # This function is a convenience wrapper function for the stuff # in Utils/PathFinding.pm sub getRoute { - my ($class, $solution, $field, $start, $dest, $avoidWalls, $randomFactor, $useManhattan, $self_call) = @_; + my ($class, $solution, $field, $start, $dest, $avoidWalls, $randomFactor, $useManhattan, $liveRoute, $addLimits) = @_; assertClass($field, 'Field') if DEBUG; if (!defined $dest->{x} || $dest->{y} eq '') { @{$solution} = () if ($solution); @@ -806,36 +897,47 @@ sub getRoute { return 0; } - my %plugin_args; - $plugin_args{self} = $class; - $plugin_args{self_call} = $self_call; - $plugin_args{start} = $closest_start; - $plugin_args{dest} = $closest_dest; - $plugin_args{field} = $field; - $plugin_args{avoidWalls} = $avoidWalls; - $plugin_args{randomFactor} = $randomFactor; - $plugin_args{useManhattan} = $useManhattan; - $plugin_args{return} = 0; - - Plugins::callHook('getRoute' => \%plugin_args); - - my $pathfinding; - if ($plugin_args{return}) { - $pathfinding = $plugin_args{pathfinding}; - } else { - $pathfinding = new PathFinding(); + + my %path_args; + $path_args{self} = $class; + $path_args{liveRoute} = $liveRoute; + + $path_args{start} = $closest_start; + $path_args{dest} = $closest_dest; + + $path_args{field} = $field; + + $path_args{avoidWalls} = $avoidWalls; + $path_args{randomFactor} = $randomFactor; + $path_args{useManhattan} = $useManhattan; + + if ($addLimits) { + my ($min_x, $max_x, $min_y, $max_y) = getLimits($path_args{field}, $path_args{start}, $path_args{dest}); + $path_args{min_x} = $min_x if defined $min_x; + $path_args{max_x} = $max_x if defined $max_x; + $path_args{min_y} = $min_y if defined $min_y; + $path_args{max_y} = $max_y if defined $max_y; } - # Calculate path - $pathfinding->reset( - start => $closest_start, - dest => $closest_dest, - field => $field, - avoidWalls => $avoidWalls, - randomFactor => $randomFactor, - useManhattan => $useManhattan, + Plugins::callHook('getRoute' => \%path_args); + + my $pathfinding = new PathFinding(); + my %reset_args = ( + field => $path_args{field}, + start => $path_args{start}, + dest => $path_args{dest}, + avoidWalls => $path_args{avoidWalls}, + randomFactor => $path_args{randomFactor}, + useManhattan => $path_args{useManhattan}, getRoute => 1 ); + + foreach my $option (qw(weight_map customWeights secondWeightMap timeout width height min_x max_x min_y max_y)) { + $reset_args{$option} = $path_args{$option} if exists $path_args{$option}; + } + + # Calculate path + $pathfinding->reset(%reset_args); return undef if (!$pathfinding); my $ret; diff --git a/src/Utils.pm b/src/Utils.pm index b60cd318a7..40c4c716ba 100644 --- a/src/Utils.pm +++ b/src/Utils.pm @@ -38,7 +38,7 @@ our @EXPORT = ( @{$Utils::DataStructures::EXPORT_TAGS{all}}, # Math - qw(get_client_solution get_client_easy_solution get_solution calcPosFromPathfinding calcTimeFromPathfinding calcStepsWalkedFromTimeAndSolution calcTimeFromSolution + qw(getLimits get_client_solution get_client_easy_solution get_solution calcPosFromPathfinding calcTimeFromPathfinding calcStepsWalkedFromTimeAndSolution calcTimeFromSolution calcPosFromTime calcTime calcPosition checkMovementDirection distance blockDistance specifiedBlockDistance adjustedBlockDistance getClientDist canAttack @@ -69,29 +69,13 @@ use constant { ################################ ################################ -## -# get_client_solution(field, pos, pos_to) -# -# Returns: the walking path from $pos to $pos_to using the A* pathfinding -# -# Reference: hercules src\map\path.c path_search -sub get_client_solution { - my ($field, $pos, $pos_to) = @_; - my $solution = []; - return $solution unless $field && $pos && $pos_to; - return $solution unless defined $pos->{x} && defined $pos->{y}; - return $solution unless defined $pos_to->{x} && defined $pos_to->{y}; +sub getLimits { + my ($field, $pos, $pos_to) = @_; - # Same cell: trivial solution - if ($pos->{x} == $pos_to->{x} && $pos->{y} == $pos_to->{y}) { - push @{$solution}, { x => $pos->{x}, y => $pos->{y} }; - return $solution; - } - - # Reject obviously invalid coordinates early - return $solution unless $field->isWalkable($pos->{x}, $pos->{y}); - return $solution unless $field->isWalkable($pos_to->{x}, $pos_to->{y}); + return unless $field && $pos && $pos_to; + return unless defined $pos->{x} && defined $pos->{y}; + return unless defined $pos_to->{x} && defined $pos_to->{y}; # Build a dynamic search box that always includes both start and dest. my $dx = abs($pos->{x} - $pos_to->{x}); @@ -109,6 +93,30 @@ sub get_client_solution { $max_x = $field->width - 1 if $max_x >= $field->width; $max_y = $field->height - 1 if $max_y >= $field->height; + return ($min_x, $max_x, $min_y, $max_y); +} + +## +# get_client_solution(field, pos, pos_to) +# +# Returns: the walking path from $pos to $pos_to using the A* pathfinding +# +# Reference: hercules src\map\path.c path_search +sub get_client_solution { + my ($field, $pos, $pos_to) = @_; + + my $solution = []; + return $solution unless $field && $pos && $pos_to; + return $solution unless defined $pos->{x} && defined $pos->{y}; + return $solution unless defined $pos_to->{x} && defined $pos_to->{y}; + return $solution if ($pos->{x} == $pos_to->{x} && $pos->{y} == $pos_to->{y}); + + # Reject obviously invalid coordinates early + return $solution unless $field->isWalkable($pos->{x}, $pos->{y}); + return $solution unless $field->isWalkable($pos_to->{x}, $pos_to->{y}); + + my ($min_x, $max_x, $min_y, $max_y) = getLimits($field, $pos, $pos_to); + # Game client uses the same A* Pathfinding as openkore but uses and inadmissible heuristic (Manhattan distance) # To better simulate the client pathfinding we tell openkore's pathfinding to use the same Manhattan heuristic # We also deactivate any custom pathfinding weights (randomFactor, avoidWalls, customWeights) @@ -207,35 +215,107 @@ sub get_solution { } } -# Currently the go-to function to get the position of a given actor on critical ocasions (eg. Attack logic) +# Mirrors rAthena's walking coordinate update logic as closely as OpenKore can with +# the movement data it actually has. +# +# What rAthena really does: +# - `unit_data::update_pos()` keeps the unit on the current cell center only while +# the sub-cell offset is still inside that cell. +# - During an active step it moves the sub-cell from 8,8 to the border and then +# across it. +# - Once the sub-cell crosses the border, rAthena already reports the main x/y as +# the next cell, even though the full cell timer has not finished yet. +# - That is why a redirected move can legitimately start one cell ahead of the +# position that a full-step-only model would still report. +# +# What OpenKore can and cannot reproduce: +# - We do have the path solution, walk speed and local receive time for the move. +# - For our own character, `ZC_NOTIFY_PLAYERMOVE` (`character_moves`) always gives +# start subcoordinates 8,8, so there is no extra hidden offset to decode there. +# - We store the raw rAthena/server `move_start_time` tick for debugging and future +# experiments, but we still anchor elapsed time to the local packet receive time +# because OpenKore has no trustworthy local<->server tick conversion. +# +# What this function changes compared with the previous full-step-only version: +# - For `Actor::You`, it walks the same path solution step by step. +# - But for the in-progress step it applies the same sub-cell math used by +# rAthena/clif packets: `24 + dir * 16 * percent`. +# - Then it converts that sub-cell back into the reported main x/y exactly like +# rAthena: values outside the 16..31 range mean the logical cell already crossed +# into the neighbor. +# - This makes the returned cell change around the half-step border crossing instead +# of only after a whole step duration has elapsed. +# - For every non-player actor, we intentionally keep using the simpler +# full-step path simulation until we have actor-specific benchmarks that show +# the new handoff logic is an improvement there too. sub calcPosFromPathfinding { my ($field, $actor, $extra_time) = @_; - my $speed = ($actor->{walk_speed} || 0.12); - my $time = time - $actor->{time_move} + $extra_time; + $extra_time ||= 0; + return unless $actor; # If Pos and PosTo are the same return Pos if ($actor->{pos}{x} == $actor->{pos_to}{x} && $actor->{pos}{y} == $actor->{pos_to}{y}) { return $actor->{pos}; } + my $speed = ($actor->{walk_speed} || 0.12); + my $time_elapsed = time - $actor->{time_move} + $extra_time; + + if ($time_elapsed <= 0) { + return $actor->{pos}; + } + my $solution; + unless (UNIVERSAL::isa($actor, "Actor::You")) { + $solution = get_solution($field, $actor->{pos}, $actor->{pos_to}); + my $steps_walked = calcStepsWalkedFromTimeAndSolution($solution, $speed, $time_elapsed); + my $pos = $solution->[$steps_walked]; + return $pos; + } + + # For the character we should have already saved the time calc and solution at + # Receive.pm::character_moves. + if ($time_elapsed >= $actor->{time_move_calc}) { + return $actor->{pos_to}; + } + $solution = $actor->{solution}; + $solution = get_solution($field, $actor->{pos}, $actor->{pos_to}) unless $solution && @{$solution}; + + return $actor->{pos_to} unless $solution && @{$solution}; + return $solution->[0] if @{$solution} == 1; + + my $time_needed_ortogonal = $speed; + my $time_needed_diagonal = $speed * (MOVE_DIAGONAL_COST / MOVE_COST); + + for (my $i = 1; $i < @{$solution}; $i++) { + my $from = $solution->[$i - 1]; + my $to = $solution->[$i]; + next unless $from && $to; - # If the actor is the character then we should have already saved the time calc and solution at Receive.pm::character_moves - if (UNIVERSAL::isa($actor, "Actor::You")) { - if ($time >= $actor->{time_move_calc}) { - return $actor->{pos_to}; + my $step_dx = $to->{x} <=> $from->{x}; + my $step_dy = $to->{y} <=> $from->{y}; + my $step_type = ($from->{x} != $to->{x}) + ($from->{y} != $to->{y}); + my $time_needed = ($step_type == 2) ? $time_needed_diagonal : $time_needed_ortogonal; + + if ($time_elapsed >= $time_needed) { + $time_elapsed -= $time_needed; + next; } - $solution = $actor->{solution}; - } else { - $solution = get_solution($field, $actor->{pos}, $actor->{pos_to}); - } + my $cell_percent = $time_needed > 0 ? ($time_elapsed / $time_needed) : 0; + my $sx = int(24.0 + $step_dx * 16.0 * $cell_percent); + my $sy = int(24.0 + $step_dy * 16.0 * $cell_percent); + my ($x, $y) = ($from->{x}, $from->{y}); - my $steps_walked = calcStepsWalkedFromTimeAndSolution($solution, $speed, $time); + $x-- if $sx < 16; + $y-- if $sy < 16; + $x++ if $sx > 31; + $y++ if $sy > 31; - my $pos = $solution->[$steps_walked]; + return { x => $x, y => $y }; + } - return $pos; + return $solution->[-1]; } # Wrapper for calcTimeFromSolution so you don't need to call get_client_solution and calcTimeFromSolution when you only need the time @@ -642,8 +722,9 @@ sub adjustedBlockDistance { my $xDistance = abs($pos1->{x} - $pos2->{x}); my $yDistance = abs($pos1->{y} - $pos2->{y}); + my $min = $xDistance > $yDistance ? $yDistance : $xDistance; - my $dist = $xDistance + $yDistance - ((2-sqrt(2)) * min($xDistance, $yDistance)); + my $dist = $xDistance + $yDistance - ((3 * $min) / 5); return $dist; } diff --git a/src/Utils/PathFinding.pm b/src/Utils/PathFinding.pm index 37d52df7dd..923e778ce7 100644 --- a/src/Utils/PathFinding.pm +++ b/src/Utils/PathFinding.pm @@ -97,30 +97,29 @@ sub reset { if ($args{field} && UNIVERSAL::isa($args{field}, 'Field') && !$args{field}->{weightMap}) { $args{field}->loadByName($args{field}->name, 1); } + + $args{avoidWalls} = 1 unless (defined $args{avoidWalls}); + $args{weight_map} = \($args{field}->{weightMap}) unless (defined $args{weight_map}); + + $args{customWeights} = 0 unless (defined $args{customWeights}); + $args{secondWeightMap} = undef unless (defined $args{secondWeightMap}); + + $args{randomFactor} = 0 unless (defined $args{randomFactor}); + $args{useManhattan} = 0 unless (defined $args{useManhattan}); + + $args{width} = $args{field}{width} unless (defined $args{width}); + $args{height} = $args{field}{height} unless (defined $args{height}); + $args{timeout} = 1500 unless (defined $args{timeout}); + $args{min_x} = 0 unless (defined $args{min_x}); + $args{max_x} = ($args{width}-1) unless (defined $args{max_x}); + $args{min_y} = 0 unless (defined $args{min_y}); + $args{max_y} = ($args{height}-1) unless (defined $args{max_y}); # Default optional arguments my %hookArgs; $hookArgs{args} = \%args; $hookArgs{return} = 1; Plugins::callHook('PathFindingReset', \%hookArgs); - if ($hookArgs{return}) { - $args{avoidWalls} = 1 unless (defined $args{avoidWalls}); - $args{weight_map} = \($args{field}->{weightMap}) unless (defined $args{weight_map}); - - $args{customWeights} = 0 unless (defined $args{customWeights}); - $args{secondWeightMap} = undef unless (defined $args{secondWeightMap}); - - $args{randomFactor} = 0 unless (defined $args{randomFactor}); - $args{useManhattan} = 0 unless (defined $args{useManhattan}); - - $args{width} = $args{field}{width} unless (defined $args{width}); - $args{height} = $args{field}{height} unless (defined $args{height}); - $args{timeout} = 1500 unless (defined $args{timeout}); - $args{min_x} = 0 unless (defined $args{min_x}); - $args{max_x} = ($args{width}-1) unless (defined $args{max_x}); - $args{min_y} = 0 unless (defined $args{min_y}); - $args{max_y} = ($args{height}-1) unless (defined $args{max_y}); - } return $class->_reset( $args{weight_map}, diff --git a/src/auto/XSTools/PathFinding/algorithm.cpp b/src/auto/XSTools/PathFinding/algorithm.cpp index ec5c9d03ef..40c68f8e20 100644 --- a/src/auto/XSTools/PathFinding/algorithm.cpp +++ b/src/auto/XSTools/PathFinding/algorithm.cpp @@ -11,6 +11,9 @@ extern "C" { #define NONE 0 #define OPEN 1 #define CLOSED 2 +#define MOVE_COST 10 +#define MOVE_DIAGONAL_COST 14 +#define INVALID_PREDECESSOR -1 #ifdef WIN32 #include @@ -66,9 +69,13 @@ CalcPath_init (CalcPath_session *session) start->x = session->startX; start->y = session->startY; start->nodeAdress = startAdress; + start->predecessor = INVALID_PREDECESSOR; + start->g = 0; start->h = heuristic_cost_estimate(start->x, start->y, goal->x, goal->y, session->useManhattan); start->f = start->h; + goal->predecessor = INVALID_PREDECESSOR; + session->initialized = 1; } @@ -105,9 +112,9 @@ CalcPath_pathStep (CalcPath_session *session) short i; - // All possible directions the character can move (in order: north, south, east, west, northeast, southeast, southwest, northwest) - short i_x[8] = {0, 0, 1, -1, 1, 1, -1, -1}; - short i_y[8] = {1, -1, 0, 0, 1, -1, -1, 1}; + // Match rAthena's neighbor expansion order exactly: SE, E, NE, N, NW, W, SW, S. + short i_x[8] = {1, 1, 1, 0, -1, -1, -1, 0}; + short i_y[8] = {-1, 0, 1, 1, 1, 0, -1, -1}; int neighbor_x; int neighbor_y; @@ -139,8 +146,8 @@ CalcPath_pathStep (CalcPath_session *session) // Set currentNode to the top node in openList, and remove it from openList. currentNode = openListGetLowest (session); - // If currentNode is the goal we have reached the destination, reconstruct and return the path. - if (goal->predecessor) { + // Match rAthena: finish only when the goal node is popped from the heap. + if (currentNode->nodeAdress == goal->nodeAdress) { //return path reconstruct_path(session, goal, start); return 1; @@ -165,22 +172,14 @@ CalcPath_pathStep (CalcPath_session *session) neighborNode = &session->currentMap[neighbor_adress]; - // If a neighbor is in closedList ignore it, it has already been expanded and has its lowest possible g_score - if (neighborNode->whichlist == CLOSED) { - continue; - } - - // First 4 neighbors in the list are in a ortogonal path and the last 4 are in a diagonal path from currentNode. - if (i >= 4) { - // If neighborNode has a diagonal path from currentNode then we can only move to it if both ortogonal composite nodes are walkable. (example: To move to the northeast both north and east must be walkable) - if (session->map_base_weight[(currentNode->y * session->width) + neighbor_x] == -1 || session->map_base_weight[(neighbor_y * session->width) + currentNode->x] == -1) { + if (i_x[i] != 0 && i_y[i] != 0) { + // Diagonal movement is only allowed if both orthogonal component cells are walkable. + if (session->map_base_weight[(currentNode->y * session->width) + neighbor_x] == -1 || session->map_base_weight[(neighbor_y * session->width) + currentNode->x] == -1) { continue; } - // We use 14 as the diagonal movement weight - distanceFromCurrent = 14; + distanceFromCurrent = MOVE_DIAGONAL_COST; } else { - // We use 10 for ortogonal movement weight - distanceFromCurrent = 10; + distanceFromCurrent = MOVE_COST; } // If avoidWalls is true we add weight to cells near walls to disencourage the algorithm to move to them. @@ -211,15 +210,21 @@ CalcPath_pathStep (CalcPath_session *session) neighborNode->f = neighborNode->g + neighborNode->h; openListAdd (session, neighborNode); - // If neighborNode is in a list it has to be in openList, since we cannot access nodes in closedList. + // Match rAthena: a better path can reopen a node that was already closed. } else { // Check if we have found a shorter path to neighborNode, if so update it to have currentNode as its predecessor. if (g_score < neighborNode->g) { neighborNode->predecessor = currentNode->nodeAdress; neighborNode->g = g_score; neighborNode->f = neighborNode->g + neighborNode->h; - // Here we could remove neighborNode from openList and add it again to get it to the right position, but reajusting it saves time. - reajustOpenListItem (session, neighborNode); + if (neighborNode->whichlist == CLOSED) { + //if (session->useManhattan) { + openListAdd(session, neighborNode); + //} + } else { + // Here we could remove neighborNode from openList and add it again to get it to the right position, but reajusting it saves time. + reajustOpenListItem(session, neighborNode); + } } } } @@ -690,4 +695,4 @@ getClientDist_inner (int start_x, int start_y, int end_x, int end_y) #ifdef __cplusplus } -#endif /* __cplusplus */ \ No newline at end of file +#endif /* __cplusplus */