diff --git a/control/config.txt b/control/config.txt index e1a87ee101..0a3926cf6c 100644 --- a/control/config.txt +++ b/control/config.txt @@ -364,6 +364,7 @@ friendlyAID showTime showTimeDomains showTimeDomainsFormat +# Wx map text and portal overlay thresholds wx_map_maxAutoSize 300 wx_map_monsterSticking 1 wx_map_npcSticking 1 @@ -413,6 +414,7 @@ noAutoSkill 0 portalCompile 1 portalRecord 2 portalRecord_recompileAfter 1 +portalUpdatePosition 1 missDamage 0 tankersList @@ -861,6 +863,10 @@ avoidObstaclesDefaultPortals { # inLockOnly 0 # notWhileSitting 0 # notWhileCasting 0 +# notWhileBeingCasted +# whileBeingCasted +# whenNoNearPartyMemberCasting +# whenNearPartyMemberCasting # whileCasting 0 # notInTown 0 # inTown 0 @@ -921,6 +927,8 @@ avoidObstaclesDefaultPortals { # target_whenStatusActive # target_whenStatusInactive # target_notWhileSitting 0 +# target_notWhileBeingCasted +# target_whileBeingCasted # target_hp # target_deltaHp # target_isJob @@ -947,6 +955,8 @@ avoidObstaclesDefaultPortals { # target_totalMisses # target_whenStatusActive # target_whenStatusInactive +# target_notWhileBeingCasted +# target_whileBeingCasted # target_whenGround # target_whenNotGround # target_dist diff --git a/control/priority.txt b/control/priority.txt index f0f7998a3b..e1deb568da 100644 --- a/control/priority.txt +++ b/control/priority.txt @@ -12,4 +12,6 @@ # Example (remove the comment character to activate them): #Hydra #Obeaune -#all \ No newline at end of file +#all + +all diff --git a/control/routeweights.txt b/control/routeweights.txt index cf4bff7efd..e6b262a683 100644 --- a/control/routeweights.txt +++ b/control/routeweights.txt @@ -39,6 +39,12 @@ WARPTOSAVEMAP 200 # Use airship AIRSHIP 200 +# Additional route cost added for each 1 zeny actually spent while routing. +ZENY 0.1 + +# Additional route cost added for each travel ticket consumed while routing. +TICKET 100 + # Maps where you can exit to the same place you came from, # which confuses routing if added to portals thoughtlessly. bat_room 10000 diff --git a/control/timeouts.txt b/control/timeouts.txt index 7adfc04328..31f305a23b 100644 --- a/control/timeouts.txt +++ b/control/timeouts.txt @@ -4,6 +4,7 @@ # The value of each variable is reffered as "x". # Server connection timeouts +# Wait x seconds for each login-stage reply before treating the connection as stalled. master 12 gamelogin 12 charlogin 12 @@ -19,6 +20,8 @@ reconnect_backoff 30,60,120,180,300,600,600,900,900,1800 # Add a random amount of seconds to reconnect time, up to a maximum of reconnect_random seconds. reconnect_random 20 +setTitle 0.2 + # Wait x seconds for a poseidon reply before disconnecting # Ignore this if you don't use Poseidon Server poseidon_wait_reply 15 @@ -26,21 +29,30 @@ poseidon_wait_reply 15 # Activate AI after x seconds after the map's loaded ai 1 +# Retry move commands every x seconds, and give up if movement does not progress within x seconds. ai_move_retry 0.4 ai_move_giveup 1.2 +# How often to prune actors whose reported positions drift implausibly far from our predicted location. +avoidDistantActors 1 + +# Wait x seconds before trying to resume standby behavior for homunculus and mercenary AI. ai_homunculus_standby 2 ai_mercenary_standby 2 # Send the attack packet every x seconds, if it hasn't been send already ai_attack 1 +# Homunculus and mercenary attack packet cadence. ai_homunculus_attack 1 ai_mercenary_attack 1 ai_attack_after_skill 0.5 +# Wait x seconds after finishing a skill before regular attack logic resumes. +# Delay between short melee re-engage attempts while dancing around a target. ai_homunculus_dance_attack_melee 0.2 +# Delay between dance-style re-engage attempts for mercenary melee and ranged attacks. ai_mercenary_dance_attack_melee 0.2 ai_mercenary_dance_attack_ranged 0.2 @@ -56,6 +68,7 @@ ai_attack_failedLOS 12 # Give up attacking a monster if it can't be reached within x seconds ai_attack_giveup 6 +# Every x seconds, let homunculus and mercenary AI look for a valid monster target. ai_homunculus_check_monster_auto 0.2 ai_mercenary_check_monster_auto 0.2 @@ -71,11 +84,14 @@ ai_attack_main 0.1 ai_homunculus_attack_main 0.1 ai_mercenary_attack_main 0.1 +# How far ahead to predict target reachability when deciding whether to keep chasing/attacking. ai_future_reachability_lookup 0.7 +# Time budget before trying anti-stuck recovery, and cooldown before retrying a previously failed target. ai_attack_unstuck 2.75 ai_attack_unfail 12 +# Time to wait before route logic considers itself stuck and tries to recover. ai_route_unstuck 2 # Pause for the specified number of seconds after taking something, @@ -95,6 +111,7 @@ ai_take .4 # Give up if unable to pickup item after x seconds ai_take_giveup 3 +# Give up gathering a specific item after x seconds. ai_items_gather_giveup 3 # Every x seconds, check items for gathering @@ -109,6 +126,15 @@ ai_transfer_items 0.15 # Delay between map_loaded and send ignoreAll command ai_ignoreAll 3 +# Delay before executing queued look packets. +ai_look 0.2 + + +# Delay auto-accepting or auto-refusing party invites. +ai_partyAuto 3 + +# Follow recovery, actor info refresh, courtesy replies, auto-deal handling, +# automatic invite denials, respawn delay, and stale actor cleanup timers. ai_follow_lost_end 10 ai_getInfo 2 ai_thanks_set 8 @@ -139,6 +165,7 @@ ai_sit_wait 0 # hp/sp sufficiency, do so when the specified number of seconds elapsed ai_safe_stand_up 2 +# Skill/item/equipment action cadence and their give-up windows. ai_skill_use 0.75 ai_skill_use_giveup 1 ai_item_use_auto 0.5 @@ -146,6 +173,7 @@ ai_item_equip_auto 0.75 ai_equipAuto_skilluse_giveup 5 ai_equip_giveup 2 +# Delays used by the different auto-teleport triggers. ai_teleport 1 ai_teleport_away 3 ai_teleport_idle 4 @@ -153,9 +181,11 @@ ai_teleport_portal 2 ai_teleport_hp 3 ai_teleport_safe_force 120 +# Retry cadence and post-request delay for teleport attempts. ai_teleport_retry 0.5 ai_teleport_delay 0.5 +# Wait x seconds for a portal transition to complete before continuing route logic. ai_portal_wait 0.5 # These timeouts are used in missing portals logic @@ -167,11 +197,14 @@ ai_route_calcRoute 1 ai_route_npcTalk 10 # These timeouts are used in npc conversation (Task::TalkNPC) +ai_npc_talk_wait_to_answer 1.5 ai_npc_talk_wait_after_close_to_cancel 0.5 ai_npc_talk_wait_after_cancel_to_destroy 0.5 +# Wait x seconds before sending the next "continue" step in an NPC dialog. ai_npc_talk_wait_before_continue 0.7 +# Auto-buy sequence pacing and NPC/packet give-up windows. ai_buyAuto 5 ai_buyAuto_wait 2 ai_buyAuto_wait_giveup_npc 15 @@ -179,11 +212,13 @@ ai_buyAuto_wait_before_buy 2 ai_buyAuto_wait_after_packet_giveup 15 ai_buyAuto_wait_after_restart 2 +# Auto-sell sequence pacing and NPC/packet give-up windows. ai_sellAuto 2 ai_sellAuto_wait_giveup_npc 15 ai_sellAuto_wait_before_sell 2 ai_sellAuto_wait_after_packet_giveup 15 +# Auto-storage sequence pacing, periodic checks, item-use delay, and overall give-up windows. ai_storageAuto 2 ai_storageAuto_getAutoCheck 2 ai_storageAuto_giveup 15 @@ -194,6 +229,7 @@ ai_cartAuto 0.15 # delay between checking if we need to do any cartAuto functions ai_cartAutoCheck 3 +# Avoidance checks, vending/shop actions, and buyer shop polling intervals. ai_avoidcheck 0.5 ai_shop 4 ai_shop_useskill_delay 5 @@ -208,6 +244,7 @@ ai_route_escape 8 # Don't change the following timeouts! ai_sync 12 +# xKore sync, keepalive, welcome text, and patch-server wait timers. injectSync 5 injectKeepAlive 12 welcomeText 4 @@ -216,9 +253,14 @@ patchserver 120 # Time to wait before load map in xKore mode ai_clientSuspend 1 +# Fine-grained delays used by route position prediction and meeting-position reachability checks. ai_route_position_prediction_delay 0.05 meetingPosition_future_reachability_lookup 0.3 # If a target is predicted to move into attack range/LOS soon, wait up to x seconds # before rerouting or dropping it. Shared by player and slave attack AI. ai_attack_allowed_waitForTarget 0.3 + +# Default command spacing for the macro and eventMacro plugins. +macro_delay 1 +eventMacro_delay 1 diff --git a/plugins/avoidObstacles/avoidObstacles.pl b/plugins/avoidObstacles/avoidObstacles.pl index 0e0c11c616..068c28b666 100644 --- a/plugins/avoidObstacles/avoidObstacles.pl +++ b/plugins/avoidObstacles/avoidObstacles.pl @@ -78,6 +78,7 @@ package avoidObstacles; use Globals; use Misc; use Plugins; +use Skill; use Utils; use Log qw(error message debug warning); use Data::Dumper; @@ -895,7 +896,11 @@ sub getObstacleName { } if (defined $obstacle->{type} && !ref $obstacle->{type} && $obstacle->{type} ne '') { - return "Spell $obstacle->{type}" if $obstacle->{type} =~ /^\d+$/; + if ($obstacle->{type} =~ /^\d+$/) { + my $skill_name = eval { Skill->new(idn => $obstacle->{type})->getName() }; + return $skill_name if defined $skill_name && $skill_name ne '' && $skill_name !~ /^Unknown(?: \d+)?$/; + return "Spell $obstacle->{type}"; + } return $obstacle->{type}; } @@ -1339,7 +1344,7 @@ sub on_route_step { 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) { if (scalar keys %{$route_portal_positions}) { - debug "[" . PLUGIN_NAME . "] No safe local route_step found, but the planned route uses a portal; keeping the current step selection.\n", 'route', 1; + debug "[" . PLUGIN_NAME . "] No safe local route_step found, but the planned route uses a portal; keeping the current step selection.\n", 'route', 2; return; } warning "[" . PLUGIN_NAME . "] No safe local route_step found; local client path would cross a prohibited cell. Requesting repath.\n"; @@ -1348,7 +1353,7 @@ sub on_route_step { } 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'; + debug "[" . PLUGIN_NAME . "] route_step adjusted from $args->{route_step} to $best_step (danger score $best_score).\n", 'route', 2; $args->{route_step} = $best_step; } } @@ -1367,13 +1372,13 @@ sub add_obstacle { 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"; + debug "[" . PLUGIN_NAME . "] Re-adding obstacle $actor after it returned to view.\n", 'route', 2; 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"; + debug "[" . PLUGIN_NAME . "] Adding obstacle $actor on location $pos->{x} $pos->{y}.\n", 'route', 2; remove_obstacle_contributions($actor->{ID}) if exists $obstaclesList{$actor->{ID}}; @@ -1445,7 +1450,7 @@ sub move_obstacle { my $pos = get_actor_position($actor); return unless $pos; - debug "[" . PLUGIN_NAME . "] Moving obstacle $actor to $pos->{x} $pos->{y}.\n"; + debug "[" . PLUGIN_NAME . "] Moving obstacle $actor to $pos->{x} $pos->{y}.\n", 'route', 2; remove_obstacle_contributions($actor->{ID}); @@ -1473,9 +1478,9 @@ sub remove_obstacle { 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"; + debug "[" . PLUGIN_NAME . "] Keeping obstacle $actor cached after it moved out of sight.\n", 'route', 2; } else { - debug "[" . PLUGIN_NAME . "] Removing obstacle $actor from " . ($pos ? "$pos->{x} $pos->{y}" : 'unknown position') . ".\n"; + debug "[" . PLUGIN_NAME . "] Removing obstacle $actor from " . ($pos ? "$pos->{x} $pos->{y}" : 'unknown position') . ".\n", 'route', 2; remove_obstacle_contributions($actor->{ID}); delete $obstaclesList{$actor->{ID}}; delete $removed_obstacle_still_in_list{$actor->{ID}}; @@ -1564,7 +1569,7 @@ sub on_AI_pre_manual_removed_obstacle_still_in_list { 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"; + debug "[" . PLUGIN_NAME . "] Removing cached obstacle $obstacle->{name} ($obstacle->{type}) from $obstacle->{pos_to}{x} $obstacle->{pos_to}{y}.\n", 'route', 2; remove_obstacle_contributions($obstacle_ID); delete $obstaclesList{$obstacle_ID}; delete $removed_obstacle_still_in_list{$obstacle_ID}; diff --git a/plugins/eCast/eCast.pl b/plugins/eCast/eCast.pl index ce8f3c0cfd..c464a5d467 100644 --- a/plugins/eCast/eCast.pl +++ b/plugins/eCast/eCast.pl @@ -17,6 +17,7 @@ # - monsterSkill # # Extra checks provided by this plugin: +# - Blocks skill casts while self is under statuses that prevent casting # - target_Element # - target_notElement # - target_Race @@ -101,6 +102,7 @@ package eCast; Plugins::register(PLUGIN_NAME, 'Extends Skill Selection and Placement', \&onUnload); my $hooks = Plugins::addHooks( + ['checkSelfCondition', \&extendedSelfCheck, undef], ['checkMonsterCondition', \&extendedCheck, undef], ['packet_skilluse', \&onPacketSkillUse, undef], ['packet/skill_use_no_damage', \&onPacketSkillUseNoDamage, undef], @@ -135,11 +137,43 @@ package eCast; isAIMode_TakesFixed_1_Damage_Magic => 'isAIMode_TakesFixed_1_Damage_Magic', isAIMode_TakesFixed_1_Damage_None => 'isAIMode_TakesFixed_1_Damage_None', ); +my @self_cast_blocking_statuses = qw( + EFST_HANDICAPSTATE_DEEPSILENCE + HEALTHSTATE_SILENCE + EFST_HEALTHSTATE_SILENCE + EFST_BODYSTATE_STUN + EFST_BODYSTATE_FREEZING + EFST_BODYSTATE_STONECURSE + EFST_HANDICAPSTATE_LIGHTNINGSTRIKE + EFST_HANDICAPSTATE_CRYSTALLIZATION + EFST_BODYSTATE_SLEEP +); sub onUnload { Plugins::delHooks($hooks); } +sub extendedSelfCheck { + my (undef, $args) = @_; + my $prefix = $args->{prefix}; + + return unless defined $prefix; + return unless $prefix =~ /^(?:attackSkillSlot|attackComboSlot|useSelf_skill|partySkill|monsterSkill)_/; + return unless $char; + + my @active_blockers = grep { $char->statusActive($_) } @self_cast_blocking_statuses; + return unless @active_blockers; + + debug( + "Will not cast $config{$prefix} because self has cast-blocking status(es): " + . join(', ', @active_blockers) + . "\n", + 'eCast', + 1 + ); + $args->{return} = 0; +} + # TODO: Revisar sub extendedCheck { my (undef, $args) = @_; diff --git a/plugins/eventMacro/eventMacro/Core.pm b/plugins/eventMacro/eventMacro/Core.pm index 0e3944ce93..0694d5d32b 100644 --- a/plugins/eventMacro/eventMacro/Core.pm +++ b/plugins/eventMacro/eventMacro/Core.pm @@ -1,6 +1,7 @@ package eventMacro::Core; use strict; +use Scalar::Util qw(weaken); use Globals; use Log qw(message error warning debug); use Utils; @@ -25,6 +26,17 @@ sub is_valid_cycle_stage { return scalar grep { $_ eq $cycle_stage } $self->get_cycle_stages; } +sub _weak_self_sub { + my ($self, $callback) = @_; + my $weak_self = $self; + weaken($weak_self); + + return sub { + my $self = $weak_self or return; + $callback->($self, @_); + }; +} + sub new { my ($class, $file) = @_; my $self = bless {}, $class; @@ -73,7 +85,15 @@ sub new { $self->define_automacro_check_state; - $self->{AI_state_change_Hook_Handle} = Plugins::addHook( 'AI_state_change', sub { my $state = $_[1]->{new}; $self->adapt_to_AI_state($state); }, undef ); + $self->{AI_state_change_Hook_Handle} = Plugins::addHook( + 'AI_state_change', + _weak_self_sub($self, sub { + my ($self, undef, $args) = @_; + my $state = $args->{new}; + $self->adapt_to_AI_state($state); + }), + undef + ); $self->{Currently_AI_state_Adapted_Automacros} = undef; @@ -101,8 +121,8 @@ sub get_log_hook_sub { return $self->{Log_Event_Sub} if (exists $self->{Log_Event_Sub}); - $self->{Log_Event_Sub} = sub { - my ($type, $domain, $level, $currentVerbosity, $message, $user_data, $near, $far) = @_; + $self->{Log_Event_Sub} = _weak_self_sub($self, sub { + my ($self, $type, $domain, $level, $currentVerbosity, $message, $user_data, $near, $far) = @_; return if (defined $domain && $domain eq 'eventMacro'); return if (defined $level && defined $currentVerbosity && $level > $currentVerbosity); $message =~ s/[\r\n]+$//; @@ -120,7 +140,7 @@ sub get_log_hook_sub { my $check_list_hash = $self->{Event_Related_Hooks}{log}; $self->manage_event_callbacks('hook', 'log', $args, $check_list_hash); - }; + }); return $self->{Log_Event_Sub}; } @@ -154,12 +174,16 @@ sub unload { $self->clean_hooks(); $self->sync_automacro_check_hooks(1); Plugins::delHook($self->{AI_state_change_Hook_Handle}) if ($self->{AI_state_change_Hook_Handle}); + delete $self->{AI_state_change_Hook_Handle}; + delete $self->{Log_Event_Sub}; } sub clean_hooks { my ($self) = @_; foreach (values %{$self->{Hook_Handles}}) {Plugins::delHook($_)} foreach (values %{$self->{Log_Hook_Handles}}) {Log::delHook($_)} + $self->{Hook_Handles} = {}; + $self->{Log_Hook_Handles} = {}; } sub set_automacro_checking_status { @@ -191,10 +215,11 @@ sub sync_automacro_check_hooks { debug "[eventMacro] Adding $cycle_stage hook for automacro checking.\n", "eventMacro", 2; $self->{AI_cycle_stage_Automacros_Check_Hook_Handles}{$cycle_stage} = Plugins::addHook( $cycle_stage, - sub { - my $state = $_[1]->{state}; + _weak_self_sub($self, sub { + my ($self, undef, $args) = @_; + my $state = $args->{state}; $self->AI_cycle_stage_checker($cycle_stage, $state); - }, + }), undef ); } @@ -570,12 +595,11 @@ sub create_callbacks { } } - my $event_sub = sub { - my $name = shift; - my $args = shift; + my $event_sub = _weak_self_sub($self, sub { + my ($self, $name, $args) = @_; my $check_list_hash = $self->{Event_Related_Hooks}{$name}; $self->manage_event_callbacks('hook', $name, $args, $check_list_hash); - }; + }); foreach my $hook_name (keys %{$self->{Event_Related_Hooks}}) { if ($hook_name eq 'log') { $self->{Log_Hook_Handles}{$hook_name} = Log::addHook( $self->get_log_hook_sub ); @@ -1401,14 +1425,14 @@ sub manage_event_callbacks { $debug_message .= ", variable value: '".$callback_args."'"; } - debug $debug_message."\n", "eventMacro", 2; + debug $debug_message."\n", "eventMacro", 3; my ($event_type_automacro_call_index, $event_type_automacro_call_priority); foreach my $automacro_index (keys %{$check_list_hash}) { my ($automacro, $conditions_indexes_hash, $check_event_type) = ($self->{Automacro_List}->get($automacro_index), $check_list_hash->{$automacro_index}, 0); - debug "[eventMacro] Conditions of state type will be checked in automacro '".$automacro->get_name()."'.\n", "eventMacro", 2; + debug "[eventMacro] Conditions of state type will be checked in automacro '".$automacro->get_name()."'.\n", "eventMacro", 3; my @conditions_indexes_array = keys %{ $conditions_indexes_hash }; @@ -1513,12 +1537,11 @@ sub manage_dynamic_hook_add_and_delete { if ($hook_name eq 'log') { $self->{Log_Hook_Handles}{$hook_name} = Log::addHook( $self->get_log_hook_sub ); } else { - my $event_sub = sub { - my $name = shift; - my $args = shift; + my $event_sub = _weak_self_sub($self, sub { + my ($self, $name, $args) = @_; my $check_list_hash = $self->{Event_Related_Hooks}{$name}; $self->manage_event_callbacks('hook', $name, $args, $check_list_hash); - }; + }); $self->{Hook_Handles}{$hook_name} = Plugins::addHook( $hook_name, $event_sub, undef ); } } @@ -1568,7 +1591,7 @@ sub should_skip_automacro_for_not_when_in_queue { return 0 unless @blocked_states; if (AI::inQueue(@blocked_states)) { - debug "[eventMacro] Automacro '".$automacro->get_name()."' will not run because eventMacro_notWhenInQueue matched one of these AI queue states: '".join("', '", @blocked_states)."'.\n", "eventMacro", 2; + debug "[eventMacro] Automacro '".$automacro->get_name()."' will not run because eventMacro_notWhenInQueue matched one of these AI queue states: '".join("', '", @blocked_states)."'.\n", "eventMacro", 3; return 1; } @@ -1718,7 +1741,10 @@ sub call_macro { if (defined $self->{Macro_Runner}) { $self->{Macro_Runner_Cycle_Stage} = $self->get_cycle_stage_for_automacro($automacro); - my $iterate_macro_sub = sub { $self->iterate_macro(); }; + my $iterate_macro_sub = _weak_self_sub($self, sub { + my ($self) = @_; + $self->iterate_macro(); + }); $self->{AI_start_Macros_Running_Hook_Handle} = Plugins::addHook( 'AI_start', $iterate_macro_sub, undef ); } else { error "[eventMacro] unable to create macro queue.\n" diff --git a/plugins/eventMacro/eventMacro/FileParser.pm b/plugins/eventMacro/eventMacro/FileParser.pm index a393051e23..518750dec8 100644 --- a/plugins/eventMacro/eventMacro/FileParser.pm +++ b/plugins/eventMacro/eventMacro/FileParser.pm @@ -265,7 +265,7 @@ sub sub_execute { # the risk yourself. Commands::run( $run ); - debug "[eventMacro] registering sub '" . $name . "'.\n", "menu"; + debug "[eventMacro] registering sub '" . $name . "'.\n", "menu", 3; } # check if on the line there commands that open new command blocks diff --git a/src/AI/Attack.pm b/src/AI/Attack.pm index 42b711fade..9f2ce2a5e5 100644 --- a/src/AI/Attack.pm +++ b/src/AI/Attack.pm @@ -36,12 +36,16 @@ use Utils::PathFinding; use Data::Dumper; $Data::Dumper::Sortkeys = 1; +# Internal stages used to tell whether the AI is still closing distance +# or is already inside the active combat loop for a target. use constant { MOVING_TO_ATTACK => 1, ATTACKING => 2, }; sub process { + # `process` is the lightweight dispatcher that watches the current AI queue, + # validates the target, and decides whether we should continue into `main`. Benchmark::begin("ai_attack") if DEBUG; my $args = AI::args(); my $action = AI::action(); @@ -50,6 +54,8 @@ sub process { my $ID; my $ataqArgs; my $stage; # 1 - moving to attack | 2 - attacking + # Figure out whether we are already attacking or are still moving/routeing + # toward a queued attack target. if (AI::action() eq "attack") { $ID = $args->{ID}; $ataqArgs = AI::args(0); @@ -65,6 +71,7 @@ sub process { $stage = MOVING_TO_ATTACK; } + # Stop immediately if the target disappeared or can no longer be resolved. if (targetGone($ataqArgs, $ID)) { finishAttacking($ataqArgs, $ID); return; @@ -84,6 +91,7 @@ sub process { my $target_is_aggressive = is_aggressive($target, undef, 0, $assistParty); my $control = mon_control($target->{name},$target->{nameID}); + # Expose the current attack context so plugins can veto or alter handling. my %plugin_args; $plugin_args{target} = $target; $plugin_args{control} = $control; @@ -99,32 +107,49 @@ sub process { return; } + # Abort when we have spent too long trying to reach or damage the target. if (shouldGiveUp($ataqArgs, $ID)) { message T("Can't reach or damage target\n"), "ai_attack"; giveUp($ataqArgs, $ID, 0); return; } + # Optionally swap to a more urgent aggressive target when our current one + # is passive, or when another aggressive target has a higher priority.txt + # priority than the monster we are currently hitting. if ($config{attackChangeTarget}) { my $aggressiveType = ($effectiveAttackMode >= 2) ? 2 : 0; my @aggressives = $effectiveAttackMode >= 0 ? ai_getAggressives($aggressiveType, $assistParty) : (); - if (!$target_is_aggressive && @aggressives) { - my $attackTarget = getBestTarget(\@aggressives, $config{attackCheckLOS}, $config{attackCanSnipe}, $char, ''); - if ($attackTarget && $attackTarget ne $target->{ID}) { - $char->sendAttackStop; - AI::dequeue() while ( AI::inQueue("attack") ); - ai_setSuspend(0); - my $new_target = Actor::get($attackTarget); - warning TF("Your target is not aggressive: %s, changing target to aggressive: %s.\n", $target, $new_target), 'ai_attack'; - $target->{droppedForAggressive} = 1; - $char->attack($attackTarget); - AI::Attack::process(); - return; - } - } - } + if (@aggressives) { + my $attackTarget = getBestTarget(\@aggressives, $config{attackCheckLOS}, $config{attackCanSnipe}, $char, ''); + if ($attackTarget && $attackTarget ne $target->{ID}) { + my $new_target = Actor::get($attackTarget); + my $current_priority = Misc::monsterPriority($target->{name}, $target->{nameID}); + my $new_priority = Misc::monsterPriority($new_target->{name}, $new_target->{nameID}); + my $switch_to_aggressive = !$target_is_aggressive; + my $switch_to_higher_priority = $target_is_aggressive && $new_priority > $current_priority; + + if ($switch_to_aggressive || $switch_to_higher_priority) { + $char->sendAttackStop; + AI::dequeue() while ( AI::inQueue("attack") ); + ai_setSuspend(0); + if ($switch_to_higher_priority) { + warning TF("Changing target to higher priority monster: %s -> %s.\n", $target, $new_target), 'ai_attack'; + } else { + warning TF("Your target is not aggressive: %s, changing target to aggressive: %s.\n", $target, $new_target), 'ai_attack'; + } + $target->{droppedForAggressive} = 1; + $char->attack($attackTarget); + AI::Attack::process(); + return; + } + } + } + } + # Refuse targets that would count as kill-stealing according to the + # configured monster ownership rules. my $cleanMonster = checkMonsterCleanness($ID); if (!$cleanMonster) { message TF("Dropping target %s - will not kill steal others\n", $target), 'ai_attack'; @@ -138,6 +163,8 @@ sub process { return; } + # `attack_auto == 3` means "only untouched monsters", so drop anything + # that has already interacted with us or been attacked. 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; @@ -161,7 +188,8 @@ sub process { return; } - # We're on route to the monster; check whether the monster has moved + # While routeing in, recalculate if the monster changed course since we + # started approaching it. if ($args->{attackID} && approach_target_route_needs_reset($ataqArgs, $target)) { reset_approach_for_moved_target($ataqArgs, $target); return; @@ -169,6 +197,8 @@ sub process { } if ($stage == ATTACKING) { + # Keep the give-up timer fair by discounting time spent suspended, + # approaching, or performing anti-stuck / avoidance movement. if (AI::args()->{suspended}) { $args->{ai_attack_giveup}{time} += time - $args->{suspended}; delete $args->{suspended}; @@ -178,14 +208,19 @@ sub process { } elsif ($args->{move_start}) { $args->{ai_attack_giveup}{time} += time - $args->{move_start}; undef $args->{unstuck}{time}; + undef $args->{unstuck}{last_hit_time}; undef $args->{move_start}; } elsif ($args->{avoiding}) { $args->{ai_attack_giveup}{time} = time; + undef $args->{unstuck}{time}; + undef $args->{unstuck}{last_hit_time}; undef $args->{avoiding}; debug "Finished avoiding movement from target $target, updating ai_attack_giveup\n", "ai_attack"; } + # Throttle the heavy combat loop; `main` performs the expensive + # positioning, skill, and attack decisions. if (timeOut($timeout{ai_attack_main})) { if ($char->{sitting}) { ai_setSuspend(0); @@ -203,6 +238,8 @@ sub process { } sub shouldAttack { + # Return true only when the AI queue represents an attack directly, or a + # route/move action that is merely the lead-in for an attack. my ($action, $args) = @_; return ( ($action eq "attack" && $args->{ID}) || @@ -212,11 +249,124 @@ sub shouldAttack { } sub shouldGiveUp { + # Give up after the configured timeout unless attackNoGiveup is active, or + # after too many anti-stuck retries. my ($args, $ID) = @_; return !$config{attackNoGiveup} && (timeOut($args->{ai_attack_giveup}) || $args->{unstuck}{count} > 5); } +sub clear_approach_failure { + my ($args) = @_; + return unless $args; + delete $args->{approachFailure}; +} + +sub note_approach_route_failure { + # When a route-to-attack meeting point fails, remember the attempted origin + # and destination so we can stop recreating the same desynced step forever. + my ($task, $error) = @_; + return unless $task && $error; + return unless $task->{meetingSubRoute} && $task->{attackID}; + + my $attack_index = AI::findAction('attack'); + return unless defined $attack_index; + + my $args = AI::args($attack_index); + return unless $args && $args->{ID} && $args->{ID} eq $task->{attackID}; + return unless $task->{dest} && $task->{dest}{pos}; + + my $origin = $task->{actor}{pos_to} || $task->{actor}{pos}; + my $dest = $task->{dest}{pos}; + my $failure = $args->{approachFailure} || {}; + my $same_dest = $failure->{dest} + && $failure->{dest}{x} == $dest->{x} + && $failure->{dest}{y} == $dest->{y}; + my $same_origin = $origin && $failure->{origin} + && $failure->{origin}{x} == $origin->{x} + && $failure->{origin}{y} == $origin->{y}; + + $args->{approachFailure} = { + pending => 1, + time => time, + errorCode => $error->{code}, + errorMessage => $error->{message}, + sameSpotCount => ($same_dest && $same_origin) ? (($failure->{sameSpotCount} || 0) + 1) : 1, + resyncMoveTried => ($same_dest && $same_origin) ? ($failure->{resyncMoveTried} || 0) : 0, + dest => { x => $dest->{x}, y => $dest->{y} }, + }; + $args->{approachFailure}{origin} = { x => $origin->{x}, y => $origin->{y} } if $origin; + + debug TF( + "[Attack] Recorded failed approach to (%d %d) from (%d %d), repeated %d time(s).\n", + $dest->{x}, + $dest->{y}, + ($origin ? $origin->{x} : -1), + ($origin ? $origin->{y} : -1), + $args->{approachFailure}{sameSpotCount}, + ), "ai_attack"; + + $args->{sentApproach} = 0; + undef $args->{move_start}; + undef $args->{avoiding}; +} + +sub abort_repeated_failed_approach { + # If the same meeting position keeps timing out while our position never + # changes, assume a server/client desync and drop the target instead of + # looping forever on the same one-cell move. + my ($args, $ID, $target, $realMyPos) = @_; + return 0 unless $args && $target && $realMyPos; + + my $failure = $args->{approachFailure}; + return 0 unless $failure && $failure->{pending}; + + if ($failure->{origin} + && ($failure->{origin}{x} != $realMyPos->{x} || $failure->{origin}{y} != $realMyPos->{y})) { + clear_approach_failure($args); + return 0; + } + + if (($failure->{sameSpotCount} || 0) < 2) { + $failure->{pending} = 0; + return 0; + } + + if (!$failure->{resyncMoveTried}) { + warning TF( + "[Attack] Repeated approach to (%d,%d) failed %d times from (%d,%d); trying to resync our position before dropping target.\n", + $failure->{dest}{x}, + $failure->{dest}{y}, + $failure->{sameSpotCount}, + ($failure->{origin} ? $failure->{origin}{x} : $realMyPos->{x}), + ($failure->{origin} ? $failure->{origin}{y} : $realMyPos->{y}), + ), "ai_attack"; + + $failure->{resyncMoveTried} = 1; + $failure->{pending} = 0; + $args->{unstuck}{time} = time; + $args->{unstuck}{last_hit_time} = time; + $args->{unstuck}{count}++; + $char->move(@{$char->{pos_to}}{qw(x y)}); + return 1; + } + + warning TF( + "[Attack] Repeated approach to (%d,%d) failed %d times from (%d,%d); dropping target to break a desync loop.\n", + $failure->{dest}{x}, + $failure->{dest}{y}, + $failure->{sameSpotCount}, + ($failure->{origin} ? $failure->{origin}{x} : $realMyPos->{x}), + ($failure->{origin} ? $failure->{origin}{y} : $realMyPos->{y}), + ), "ai_attack"; + + clear_approach_failure($args); + giveUp($args, $ID, 0); + return 1; +} + sub approach_target_route_needs_reset { + # Detect whether the target moved to a new destination after we already + # committed to an approach route, which makes the old meeting point stale. my ($args, $target) = @_; return 0 unless $args && $target; return 0 if $target->{type} eq 'Unknown'; @@ -235,6 +385,8 @@ sub approach_target_route_needs_reset { } sub reset_approach_for_moved_target { + # Clear route-specific state so the next pass can compute a fresh approach + # path for the monster's new movement direction. my ($args, $target) = @_; return unless $args && $target; @@ -246,11 +398,15 @@ sub reset_approach_for_moved_target { $args->{monsterLastMoveTime} = $target->{time_move}; $args->{monsterLastMovePosTo} = { %{$target->{pos_to}} } if $target->{pos_to}; undef $args->{unstuck}{time}; + undef $args->{unstuck}{last_hit_time}; undef $args->{avoiding}; undef $args->{move_start}; + clear_approach_failure($args); } sub giveUp { + # Centralized cleanup for abandoned targets. This records why we failed, + # clears attack queue state, and optionally teleports away. my ($args, $ID, $reason) = @_; my $target = Actor::get($ID); if ($monsters{$ID}) { @@ -273,6 +429,8 @@ sub giveUp { } sub targetGone { + # Treat missing or dead actors as gone so the attack loop can terminate + # without waiting for additional state updates. my ($args, $ID) = @_; my $target = Actor::get($ID, 1); unless ($target) { @@ -285,6 +443,8 @@ sub targetGone { } sub finishAttacking { + # Finalize the encounter: clear the attack queue, run death/loss handling, + # loot when appropriate, and notify hooks that combat has ended. my ($args, $ID) = @_; $timeout{'ai_attack'}{'time'} -= $timeout{'ai_attack'}{'timeout'}; AI::dequeue() while (AI::inQueue("attack")); @@ -304,6 +464,8 @@ sub finishAttacking { ai_clientSuspend(0, $timeout{'ai_attack_waitAfterKill'}{'timeout'}); } + # Maintain the historical per-monster kill counters used elsewhere by the + # bot and logs. ## kokal start ## mosters counting my $i = 0; @@ -337,6 +499,8 @@ sub finishAttacking { } sub find_kite_position { + # Try to find a safe retreat tile that preserves enough distance to keep + # attacking, then launch a short route to that tile. my ($args, $inAdvance, $target, $realMyPos, $realMonsterPos, $noAttackMethodFallback_runFromTarget) = @_; my $maxDistance; @@ -384,6 +548,8 @@ sub find_kite_position { } sub resolve_movetoattack_pos { + # When local movement prediction says an actor should already have arrived, + # snap its tracked position to the predicted endpoint to prevent desync. my ($actor) = @_; return unless (actorFinishedMovement($actor, $field)); debug TF("[Attack] [%s] Fixing failed to attack target, setting actor position to: %s %s\n", $actor, $actor->{movetoattack_pos}{x}, $actor->{movetoattack_pos}{y} ), "ai_attack"; @@ -398,6 +564,8 @@ sub resolve_movetoattack_pos { } sub main { + # `main` is the core combat brain. It predicts movement, chooses the attack + # method, handles kiting/chasing, and finally sends weapon or skill attacks. my $args = AI::args(); my $ID = $args->{ID}; @@ -416,6 +584,8 @@ sub main { my $target = Actor::get($ID); + # Reset per-loop range adjustments and reconcile any temporary predicted + # positions left over from move-to-attack logic. if (!exists $args->{temporary_extra_range} || !defined $args->{temporary_extra_range}) { $args->{temporary_extra_range} = 0; } @@ -436,6 +606,8 @@ sub main { } } + # Build a predicted "real" position for both player and monster so range and + # line-of-sight checks are based on movement in flight, not only stale cells. 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); @@ -449,6 +621,10 @@ sub main { my $realMonsterDist = blockDistance($realMyPos, $realMonsterPos); my $clientDist = getClientDist($realMyPos, $realMonsterPos); + if (abort_repeated_failed_approach($args, $ID, $target, $realMyPos)) { + return; + } + if (!exists $args->{firstLoop}) { $args->{firstLoop} = 1; } else { @@ -458,7 +634,12 @@ sub main { my $hitYou = ((defined $args->{dmgToYou_last} && $args->{dmgToYou_last} != $target->{dmgToYou}) || (defined $args->{missedYou_last} && $args->{missedYou_last} != $target->{missedYou})) ? 1 : 0; my $casOnYou = (defined $args->{castOnToYou_last} && $args->{castOnToYou_last} != $target->{castOnToYou}) ? 1 : 0; my $youHitTarget = ((defined $args->{dmgFromYou_last} && $args->{dmgFromYou_last} != $target->{dmgFromYou}) || (defined $args->{missedFromYou_last} && $args->{missedFromYou_last} != $target->{missedFromYou})) ? 1 : 0; + + if ($youHitTarget) { + $args->{unstuck}{last_hit_time} = time; + } + # Any exchange of damage, misses, or casts marks the fight as engaged. if ($hitYou || $casOnYou || $args->{dmgFromYou_last} != $target->{dmgFromYou} || ($args->{firstLoop} && ($target->{dmgToYou} || $target->{missedYou} || $target->{dmgFromYou} || $target->{castOnToYou}))) { $target->{engaged} = 1 if (!exists $target->{engaged} || !$target->{engaged}); } @@ -483,7 +664,8 @@ sub main { Benchmark::end("ai_attack (part 1.1)") if DEBUG; Benchmark::begin("ai_attack (part 1.2)") if DEBUG; - # Determine what combo skill to use + # Highest priority: see whether we are in a combo window that should replace + # the normal attack flow for this pass. delete $args->{attackMethod}; my $combo_state = $char->{combo_state}; @@ -494,7 +676,7 @@ sub main { } my $i = 0; - while (exists $config{"attackComboSlot_$i"}) { + while (exists $config{"attackComboSlot_$i"} && !$char->{muted}) { next unless (defined $config{"attackComboSlot_$i"}); next unless ($config{"attackComboSlot_${i}_afterSkill"}); @@ -539,7 +721,8 @@ sub main { $i++; } - # Determine what skill to use to attack + # Otherwise fall back to the standard priority: weapon by default, then + # override with the first attackSkillSlot whose conditions currently match. if (!$args->{attackMethod}{type}) { if ($config{'attackUseWeapon'}) { $args->{attackMethod}{type} = "weapon"; @@ -552,7 +735,7 @@ sub main { } $i = 0; - while (exists $config{"attackSkillSlot_$i"}) { + while (exists $config{"attackSkillSlot_$i"} && !$char->{muted}) { next unless (defined $config{"attackSkillSlot_$i"}); my $skill = new Skill(auto => $config{"attackSkillSlot_$i"}); @@ -598,6 +781,8 @@ sub main { # proved we could hit out of nominal range. Persisting it here lets melee # attacks get stuck spamming from clientDist 2 without re-approaching. + # Evaluate whether the chosen attack method can be executed from the current + # predicted positions. # -2: undefined attackMethod # -1: No LOS # 0: out of range @@ -610,6 +795,15 @@ sub main { } my $canAttack_fail_string = (($canAttack == -2) ? "No Method" : (($canAttack == -1) ? "No LOS" : (($canAttack == 0) ? "No Range" : "OK"))); + my $future_wait_timeout = $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'}; + my $future_wait_max_time = $future_wait_timeout ? ($future_wait_timeout * 3) : 0; + + if ($youHitTarget) { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time} if exists $args->{ai_attack_allowed_waitForTarget_give_up}; + delete $args->{ai_attack_allowed_waitForTarget_disabled_until_hit}; + } elsif ($canAttack != 0 && $canAttack != -1) { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time} if exists $args->{ai_attack_allowed_waitForTarget_give_up}; + } # Here we check if the monster which we are waiting to get closer to us is in fact close enough # If it is close enough delete the ai_attack_failed_waitForAgressive_give_up keys and loop attack logic @@ -624,8 +818,8 @@ sub main { delete $args->{ai_attack_failed_waitForAgressive_give_up}{time}; } - # Here we check if we have finished moving to the meeting position to attack our target, only checks this if attackWaitApproachFinish is set to 1 in config - # If so sets sentApproach to 0 + # If we are already walking to a meeting position, keep waiting, reset the + # route if the target drifted, or clear the flag once we can attack again. if ($args->{sentApproach}) { if (approach_target_route_needs_reset($args, $target)) { reset_approach_for_moved_target($args, $target); @@ -635,6 +829,7 @@ sub main { if ($realMyPos->{x} == $myPosTo->{x} && $realMyPos->{y} == $myPosTo->{y}) { debug TF("[Ended Approaching] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; $args->{sentApproach} = 0; + clear_approach_failure($args); } elsif ($config{"attackWaitApproachFinish"}) { debug TF("[attackWaitApproachFinish - Waiting] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; @@ -643,6 +838,7 @@ sub main { } elsif ($canAttack == 2) { debug TF("[Approaching - Can now attack] %s (%d %d), target %s (%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; $args->{sentApproach} = 0; + clear_approach_failure($args); } } @@ -650,8 +846,8 @@ sub main { my $failed_runFromTarget = 0; my $hitTarget_when_not_possible = 0; - # Here, if runFromTarget is active, we check if the target mob is closer to us than the minimun distance specified in runFromTarget_dist - # If so try to kite it + # First defensive option: kite away when the target gets closer than the + # configured minimum distance for run-from-target behavior. if ( !$found_action && $config{"runFromTarget"} && @@ -665,8 +861,8 @@ sub main { } } - # Here, if runFromTarget is active, and we can't attack right now (eg. all skills in cooldown) we check if the target mob is closer to us than the minimun distance specified in runFromTarget_noAttackMethodFallback_minStep - # If so try to kite it using maxdistance of runFromTarget_noAttackMethodFallback_attackMaxDist + # Second defensive option: if we currently have no valid attack method at + # all, still try to kite using the fallback run-from-target settings. if ( !$found_action && $canAttack == -2 && @@ -716,9 +912,8 @@ sub main { } } - # Here we decide what to do when a mob we have already hit is no longer in range or we have no LOS to it - # We also check if we have waited too long for the monster which we are waiting to get closer to us to approach - # TODO: Maybe we should separate this into 2 sections, one for out of range and another for no LOS - low priority + # If we already tagged the monster, optionally wait a little for it to walk + # back into range/LOS before giving up entirely. if ( !$found_action && $config{"attackBeyondMaxDistance_waitForAgressive"} && @@ -741,20 +936,34 @@ sub main { if ( !$found_action && - $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'} && + $future_wait_timeout && ($canAttack == 0 || $canAttack == -1) && !$hitTarget_when_not_possible ) { - my $futureMonsterPos = calcPosFromPathfinding($field, $target, ($extra_time + $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'})); + my $futureMonsterPos = calcPosFromPathfinding($field, $target, ($extra_time + $future_wait_timeout)); my $futurecanAttack = canAttack($field, $realMyPos, $futureMonsterPos, $config{attackCanSnipe}, $args->{attackMethod}{maxDistance}, $config{clientSight}); - if ($futurecanAttack) { - debug 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; + if ($futurecanAttack == 1 && !$args->{ai_attack_allowed_waitForTarget_disabled_until_hit}) { + $args->{ai_attack_allowed_waitForTarget_give_up}{timeout} = $future_wait_max_time if !$args->{ai_attack_allowed_waitForTarget_give_up}{timeout}; + $args->{ai_attack_allowed_waitForTarget_give_up}{time} = time if !$args->{ai_attack_allowed_waitForTarget_give_up}{time}; + + my $waited = time - $args->{ai_attack_allowed_waitForTarget_give_up}{time}; + if (timeOut($args->{ai_attack_allowed_waitForTarget_give_up})) { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time}; + $args->{ai_attack_allowed_waitForTarget_disabled_until_hit} = 1; + warning TF("[Attack] Predictive wait timed out after [%s/%s secs]; disabling predictive wait until we hit this target again. currentState %s, attackMethod %s, you %s (%d %d), target %s real(%d %d) [(%d %d) -> (%d %d)], future(%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", + $waited, $future_wait_max_time, $canAttack_fail_string, $args->{attackMethod}{type}, $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}, $futureMonsterPos->{x}, $futureMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; + } else { + my $remaining = $future_wait_max_time - $waited; + debug TF("[Attack] Predictive wait active: currentState %s, futureState OK in [%s secs], waited [%s/%s], remaining [%s], attackMethod %s, you %s (%d %d), target %s real(%d %d) [(%d %d) -> (%d %d)], future(%d %d), blockDist %d, clientDist %d, maxDistance %d, dmgFromYou %d.\n", + $canAttack_fail_string, $future_wait_timeout, $waited, $future_wait_max_time, $remaining, $args->{attackMethod}{type}, $char, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}, $futureMonsterPos->{x}, $futureMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}, $target->{dmgFromYou}), 'ai_attack'; + $found_action = 1; + } + } else { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time} if exists $args->{ai_attack_allowed_waitForTarget_give_up}; } } - # Here we decide what to do with a mob which is out of range or we have no LOS to + # If we still cannot attack, compute a better meeting position and walk to it. if ( !$found_action && ($canAttack == 0 || $canAttack == -1) && @@ -806,7 +1015,8 @@ sub main { (!$config{"runFromTarget"} || $realMonsterDist >= $config{"runFromTarget_dist"} || $failed_runFromTarget) && (!$config{"tankMode"} || !$target->{dmgFromYou}) ) { - # Attack the target. In case of tanking, only attack if it hasn't been hit once. + # We are in range and not committed to a movement action, so execute the + # chosen attack method. In tank mode, only strike until initial aggro is secured. if (!$args->{firstAttack}) { $args->{firstAttack} = 1; $target->{sentAttack} = 1; @@ -814,12 +1024,14 @@ sub main { } $args->{unstuck}{time} = time if (!$args->{unstuck}{time}); - if (!$target->{dmgFromYou} && timeOut($args->{unstuck})) { - # We are close enough to the target, and we're trying to attack it, - # but some time has passed and we still haven't dealed any damage. - # Our recorded position might be out of sync, so try to unstuck + $args->{unstuck}{last_hit_time} = time if (!$args->{unstuck}{last_hit_time}); + if (timeOut($args->{unstuck}{last_hit_time}, $args->{unstuck}{timeout})) { + # We are close enough to the target, but have not received any new hit + # feedback for a while. This covers both the "never hit once" case and + # the "hit before, then silently desynced" case common with skills. $args->{unstuck}{time} = time; - debug("Attack - trying to unstuck\n", "ai_attack"); + $args->{unstuck}{last_hit_time} = time; + debug("Attack - trying to unstuck after in-range hit timeout\n", "ai_attack"); $char->move(@{$myPosTo}{qw(x y)}); $args->{unstuck}{count}++; } @@ -865,7 +1077,7 @@ sub main { "attackSkill", $config{"attackSkillSlot_${slot}_isStartSkill"} ? 1 : 0, ); - debug "[attackSkillSlot] Auto-skill on monster ".getActorName($ID).": ".qq~$config{"attackSkillSlot_$slot"} (lvl $skill_lvl)\n~, "ai_attack"; + debug "[attackSkillSlot] Auto-skill on target $target ($realMonsterPos->{x} $realMonsterPos->{y}) ($realMonsterDist blocks away); we're at ($realMyPos->{x} $realMyPos->{y}): ".qq~$config{"attackSkillSlot_$slot"} (lvl $skill_lvl)\n~, "ai_attack"; # TODO: We sould probably add a runFromTarget_inAdvance logic here also, we could want to kite using skills, but only instant cast ones like double strafe I believe $timeout{ai_attack_after_skill}{time} = time; @@ -900,6 +1112,8 @@ sub main { } + # Tank mode fallback: stop re-sending attacks once we already transferred + # aggro and just keep the encounter alive by monitoring damage updates. if (!$found_action && $config{tankMode}) { if ($args->{dmgTo_last} != $target->{dmgTo}) { $args->{ai_attack_giveup}{time} = time; diff --git a/src/AI/CoreLogic.pm b/src/AI/CoreLogic.pm index 94f6e82394..b7d2ea0ee9 100644 --- a/src/AI/CoreLogic.pm +++ b/src/AI/CoreLogic.pm @@ -88,6 +88,7 @@ sub iterate { processAutoAttack() if AI::state() == AI::AUTO(); $char->processTask("route", onError => sub { my ($task, $error) = @_; + AI::Attack::note_approach_route_failure($task, $error); if (!($task->isa('Task::MapRoute') && $error->{code} == Task::MapRoute::TOO_MUCH_TIME()) && !($task->isa('Task::Route') && $error->{code} == Task::Route::TOO_MUCH_TIME())) { error("$error->{message}\n"); @@ -275,7 +276,8 @@ sub processActorAvoid { my $max_dist = $config{clientSight} + 1; my $max_to_delete = $max_dist*2; - if (timeOut($timeout{'avoidDistantActors'}{'time'}, 1)) { + my $avoid_distant_actors_timeout = $timeout{'avoidDistantActors'}{'timeout'} || 1; + if (timeOut($timeout{'avoidDistantActors'}{'time'}, $avoid_distant_actors_timeout)) { $timeout{'avoidDistantActors'}{'time'} = time; foreach my $list ($playersList, $monstersList, $npcsList, $petsList, $portalsList, $slavesList, $elementalsList) { for my $actor (@$list) { @@ -484,16 +486,17 @@ sub processReAddMissingPortals { ##### PORTALRECORD ##### # Automatically record new unknown portals sub processPortalRecording { - return unless $config{portalRecord}; + return unless ($config{portalRecord} || $config{portalUpdatePosition}); return unless $ai_v{portalTrace_mapChanged} && timeOut($ai_v{portalTrace_mapChanged}, 0.5); delete $ai_v{portalTrace_mapChanged}; + my $portal_update_candidate = delete $ai_v{portalUpdatePosition_candidate}; - debug "Checking for new portals...\n", "portalRecord"; + debug "[processPortalRecording] Checking for new portals...\n", "portalRecord"; my $first = 1; my ($foundID, $smallDist, $dist); if (!$field->baseName) { - debug "Field name not known - abort\n", "portalRecord"; + debug "[processPortalRecording] Field name not known - abort\n", "portalRecord"; return; } @@ -516,14 +519,14 @@ sub processPortalRecording { $sourceID = $portals_old{$foundID}{nameID}; %sourcePos = %{$portals_old{$foundID}{pos}}; $sourceIndex = $foundID; - debug "Source portal: $sourceMap ($sourcePos{x}, $sourcePos{y})\n", "portalRecord"; + debug "[processPortalRecording] Source portal: $sourceMap ($sourcePos{x}, $sourcePos{y})\n", "portalRecord"; } else { - debug "No source portal found.\n", "portalRecord"; + debug "[processPortalRecording] No source portal found.\n", "portalRecord"; return; } #if (defined portalExists($sourceMap, \%sourcePos)) { - # debug "Source portal is already in portals.txt - abort\n", "portalRecord"; + # debug "[processPortalRecording] Source portal is already in portals.txt - abort\n", "portalRecord"; # return; #} @@ -546,95 +549,214 @@ sub processPortalRecording { # Sanity checks if (!defined $foundID) { - debug "No destination portal found.\n", "portalRecord"; + debug "[processPortalRecording] No destination portal found.\n", "portalRecord"; return; } + + my $destMap = $field->baseName; + my %arrivalPos = %{$char->{pos_to}}; + if ($config{portalUpdatePosition} + && _tryUpdateKnownPortalPositions($portal_update_candidate, $sourceMap, \%sourcePos, $destMap, \%arrivalPos)) { + return; + } + #if (defined portalExists($field->baseName, $portals{$foundID}{pos})) { # debug "Destination portal is already in portals.txt\n", "portalRecord"; # last PORTALRECORD; #} if (defined portalExists2($sourceMap, \%sourcePos, $field->baseName, $portals{$foundID}{pos})) { - debug "This portal is already in portals.txt\n", "portalRecord"; + debug "[processPortalRecording] This portal is already in portals.txt\n", "portalRecord"; return; } if (defined portalExistsAirship($sourceMap, \%sourcePos)) { - debug "This portal is already in portals_airships.txt\n", "portalRecord"; + debug "[processPortalRecording] This portal is already in portals_airships.txt\n", "portalRecord"; return; } # And finally, record the portal information - my ($destMap, $destID, %destPos); - $destMap = $field->baseName; + my ($destID, %destPos); $destID = $portals{$foundID}{nameID}; %destPos = %{$portals{$foundID}{pos}}; - debug "Destination portal: $destMap ($destPos{x}, $destPos{y})\n", "portalRecord"; + debug "[processPortalRecording] Destination portal: $destMap ($destPos{x}, $destPos{y})\n", "portalRecord"; $portals{$foundID}{name} = "$destMap -> $sourceMap"; $portals_old{$sourceIndex}{name} = "$sourceMap -> $destMap"; - my ($ID, $destName); my $recorded = 0; # Record information about destination portal if ($config{portalRecord} > 1 && !defined portalExists($destMap, $portals{$foundID}{pos})) { - $ID = "$destMap $destPos{x} $destPos{y}"; - $portals_lut{$ID}{source}{map} = $destMap; - $portals_lut{$ID}{source}{x} = $destPos{x}; - $portals_lut{$ID}{source}{y} = $destPos{y}; - $destName = "$sourceMap $sourcePos{x} $sourcePos{y}"; - $portals_lut{$ID}{dest}{$destName}{map} = $sourceMap; - $portals_lut{$ID}{dest}{$destName}{x} = $sourcePos{x}; - $portals_lut{$ID}{dest}{$destName}{y} = $sourcePos{y}; - - message TF("Recorded new portal (destination): %s (%s, %s) -> %s (%s, %s)\n", $destMap, $destPos{x}, $destPos{y}, $sourceMap, $sourcePos{x}, $sourcePos{y}), "portalRecord"; - updatePortalLUT(Settings::getTableFilename("portals.txt"), + if ($config{portalUpdatePosition} + && _tryUpdateNearbyPortalRecordSibling($destMap, \%destPos, $sourceMap, \%sourcePos)) { + $recorded = 1; + } else { + $ID = "$destMap $destPos{x} $destPos{y}"; + $portals_lut{$ID}{source}{map} = $destMap; + $portals_lut{$ID}{source}{x} = $destPos{x}; + $portals_lut{$ID}{source}{y} = $destPos{y}; + $destName = "$sourceMap $sourcePos{x} $sourcePos{y}"; + $portals_lut{$ID}{dest}{$destName}{map} = $sourceMap; + $portals_lut{$ID}{dest}{$destName}{x} = $sourcePos{x}; + $portals_lut{$ID}{dest}{$destName}{y} = $sourcePos{y}; + + my $updated = updatePortalLUT(Settings::getTableFilename("portals.txt"), $destMap, $destPos{x}, $destPos{y}, $sourceMap, $sourcePos{x}, $sourcePos{y}); - Plugins::callHook('portal_exist2', { - srcMap => $destMap, - srcx => $destPos{x}, - srcy => $destPos{y}, - dstMap => $sourceMap, - dstx => $sourcePos{x}, - dsty => $sourcePos{y} - }); - $recorded = 1; + + if ($updated == 1) { + message TF("[processPortalRecording] Recorded new portal (destination): %s (%s, %s) -> %s (%s, %s)\n", $destMap, $destPos{x}, $destPos{y}, $sourceMap, $sourcePos{x}, $sourcePos{y}), "portalRecord"; + Plugins::callHook('portal_exist2', { + srcMap => $destMap, + srcx => $destPos{x}, + srcy => $destPos{y}, + dstMap => $sourceMap, + dstx => $sourcePos{x}, + dsty => $sourcePos{y} + }); + $recorded = 1; + } elsif ($updated == 2) { + debug "[processPortalRecording] Destination portal already canonical in portals.txt\n", "portalRecord"; + } + } } # Record information about the source portal if (!defined portalExists($sourceMap, \%sourcePos)) { - $ID = "$sourceMap $sourcePos{x} $sourcePos{y}"; - $portals_lut{$ID}{source}{map} = $sourceMap; - $portals_lut{$ID}{source}{x} = $sourcePos{x}; - $portals_lut{$ID}{source}{y} = $sourcePos{y}; - $destName = "$destMap $destPos{x} $destPos{y}"; - $portals_lut{$ID}{dest}{$destName}{map} = $destMap; - $portals_lut{$ID}{dest}{$destName}{x} = $destPos{x}; - $portals_lut{$ID}{dest}{$destName}{y} = $destPos{y}; - - message TF("Recorded new portal (source): %s (%s, %s) -> %s (%s, %s)\n", $sourceMap, $sourcePos{x}, $sourcePos{y}, $destMap, $char->{pos}{x}, $char->{pos}{y}), "portalRecord"; - updatePortalLUT(Settings::getTableFilename("portals.txt"), + if ($config{portalUpdatePosition} + && _tryUpdateNearbyPortalRecordSibling($sourceMap, \%sourcePos, $destMap, $char->{pos})) { + $recorded = 1; + } else { + $ID = "$sourceMap $sourcePos{x} $sourcePos{y}"; + $portals_lut{$ID}{source}{map} = $sourceMap; + $portals_lut{$ID}{source}{x} = $sourcePos{x}; + $portals_lut{$ID}{source}{y} = $sourcePos{y}; + $destName = "$destMap $destPos{x} $destPos{y}"; + $portals_lut{$ID}{dest}{$destName}{map} = $destMap; + $portals_lut{$ID}{dest}{$destName}{x} = $destPos{x}; + $portals_lut{$ID}{dest}{$destName}{y} = $destPos{y}; + + my $updated = updatePortalLUT(Settings::getTableFilename("portals.txt"), $sourceMap, $sourcePos{x}, $sourcePos{y}, $destMap, $char->{pos}{x}, $char->{pos}{y}); - Plugins::callHook('portal_exist2', { - srcMap => $sourceMap, - srcx => $sourcePos{x}, - srcy => $sourcePos{y}, - dstMap => $destMap, - dstx => $char->{pos}{x}, - dsty => $char->{pos}{y} - }); - $recorded = 1; + + if ($updated == 1) { + message TF("[processPortalRecording] Recorded new portal (source): %s (%s, %s) -> %s (%s, %s)\n", $sourceMap, $sourcePos{x}, $sourcePos{y}, $destMap, $char->{pos}{x}, $char->{pos}{y}), "portalRecord"; + Plugins::callHook('portal_exist2', { + srcMap => $sourceMap, + srcx => $sourcePos{x}, + srcy => $sourcePos{y}, + dstMap => $destMap, + dstx => $char->{pos}{x}, + dsty => $char->{pos}{y} + }); + $recorded = 1; + } elsif ($updated == 2) { + debug "[processPortalRecording] Source portal already canonical in portals.txt\n", "portalRecord"; + } + } } if ($recorded && $config{portalRecord_recompileAfter}) { - Settings::loadByRegexp(qr/portals/); - Misc::compilePortals() if Misc::compilePortals_check(); + recompilePortals(); + } +} + +sub _tryUpdateKnownPortalPositions { + my ($candidate, $sourceMap, $sourcePos, $destMap, $destPos) = @_; + return 0 unless $candidate; + return 0 unless ($candidate->{oldSourceMap} eq $sourceMap && $candidate->{oldDestMap} eq $destMap); + return 1 if ($candidate->{oldSourceX} == $sourcePos->{x} + && $candidate->{oldSourceY} == $sourcePos->{y} + && $candidate->{oldDestX} == $destPos->{x} + && $candidate->{oldDestY} == $destPos->{y}); + + my $updated = FileParsers::replacePortalLUT( + Settings::getTableFilename("portals.txt"), + $candidate->{oldSourceMap}, $candidate->{oldSourceX}, $candidate->{oldSourceY}, + $candidate->{oldDestMap}, $candidate->{oldDestX}, $candidate->{oldDestY}, + "$sourceMap $sourcePos->{x} $sourcePos->{y} $destMap $destPos->{x} $destPos->{y}", + ); + return 0 unless $updated; + return 1 if $updated == 2; + + warning TF("[processPortalRecording] Updated portal coordinates in portals.txt: %s (%s,%s) -> %s (%s,%s) became %s (%s,%s) -> %s (%s,%s)\n", + $candidate->{oldSourceMap}, $candidate->{oldSourceX}, $candidate->{oldSourceY}, + $candidate->{oldDestMap}, $candidate->{oldDestX}, $candidate->{oldDestY}, + $sourceMap, $sourcePos->{x}, $sourcePos->{y}, + $destMap, $destPos->{x}, $destPos->{y}), "portalRecord"; + + recompilePortals(); + return 1; +} + +sub _tryUpdateNearbyPortalRecordSibling { + my ($sourceMap, $sourcePos, $destMap, $destPos) = @_; + + my ($siblingSource, $siblingDest) = _findNearbyPortalRecordSibling($sourceMap, $sourcePos, $destMap, $destPos); + return 0 unless ($siblingSource && $siblingDest); + + my $updated = FileParsers::replacePortalLUT( + Settings::getTableFilename("portals.txt"), + $siblingSource->{map}, $siblingSource->{x}, $siblingSource->{y}, + $siblingDest->{map}, $siblingDest->{x}, $siblingDest->{y}, + "$sourceMap $sourcePos->{x} $sourcePos->{y} $destMap $destPos->{x} $destPos->{y}", + ); + return 0 unless $updated; + return 1 if $updated == 2; + + warning TF("[processPortalRecording] Updated nearby portal sibling in portals.txt: %s (%s,%s) -> %s (%s,%s) became %s (%s,%s) -> %s (%s,%s)\n", + $siblingSource->{map}, $siblingSource->{x}, $siblingSource->{y}, + $siblingDest->{map}, $siblingDest->{x}, $siblingDest->{y}, + $sourceMap, $sourcePos->{x}, $sourcePos->{y}, + $destMap, $destPos->{x}, $destPos->{y}), "portalRecord"; + + recompilePortals(); + return 1; +} + +sub _findNearbyPortalRecordSibling { + my ($sourceMap, $sourcePos, $destMap, $destPos) = @_; + + my $bestSource; + my $bestDest; + my $bestScore; + my $maxSourceDrift = 2; + my $maxDestDrift = 6; + + foreach my $portalID (keys %portals_lut) { + my $entry = $portals_lut{$portalID}; + next if Misc::isRouteSourceRemoved($entry); + next unless ($entry->{source}{map} eq $sourceMap && $entry->{dest}); + + my $sourceDist = blockDistance($entry->{source}, $sourcePos); + next if $sourceDist > $maxSourceDrift; + + foreach my $destID (keys %{$entry->{dest}}) { + my $destEntry = $entry->{dest}{$destID}; + next unless ($destEntry->{map} eq $destMap); + + my $destDist = blockDistance($destEntry, $destPos); + next if $destDist > $maxDestDrift; + + my $score = $sourceDist + $destDist; + next if defined $bestScore && $score >= $bestScore; + + $bestSource = $entry->{source}; + $bestDest = $destEntry; + $bestScore = $score; + } } + + return ($bestSource, $bestDest); +} + +sub recompilePortals { + Settings::loadByRegexp(qr/portals/); + Misc::compilePortals() if Misc::compilePortals_check(); } ##### ESCAPE UNKNOWN MAPS ##### @@ -854,43 +976,51 @@ sub processTake { if (AI::action() eq "take" && !(my $item = $items{AI::args()->{ID}})) { AI::dequeue(); - } elsif (AI::action() eq "take" && timeOut(AI::args()->{ai_take_giveup})) { - message TF("Failed to take %s (%s) from (%s, %s) to (%s, %s)\n", $item->{name}, $item->{binID}, $char->{pos}{x}, $char->{pos}{y}, $item->{pos}{x}, $item->{pos}{y}); - $item->{take_failed}++; - AI::dequeue(); - } elsif (AI::action() eq "take") { my $myPos = calcPosFromPathfinding($field, $char); my $dist = blockDistance($item->{pos}, $myPos); - debug "Planning to take $item->{name} ($item->{binID}), distance $dist\n", "drop"; - if ($char->{sitting}) { - stand(); + if (timeOut(AI::args()->{ai_take_giveup})) { + message TF("Failed to take %s (%s) from (%s, %s) to (%s, %s) - dist %s\n", $item->{name}, $item->{binID}, $myPos->{x}, $myPos->{y}, $item->{pos}{x}, $item->{pos}{y}, $dist); + $item->{take_failed}++; + AI::dequeue(); - } elsif ($dist <= 2 && $config{'itemsTakeGreed'} && $char->{skills}{BS_GREED}{lv} >= 1) { - my $skill = new Skill(handle => 'BS_GREED'); - ai_skillUse2($skill, $char->{skills}{BS_GREED}{lv}, 1, 0, $char, "BS_GREED"); + } else { + debug "Planning to take $item->{name} ($item->{binID}) at ($item->{pos}{x}, $item->{pos}{y}), distance $dist from us at ($myPos->{x}, $myPos->{y})\n", "drop"; - } elsif ($dist > 1 && timeOut(AI::args()->{time_route}, $timeout{ai_take_giveup}{timeout})) { - my $pos = $item->{pos}; - AI::args()->{time_route} = time; - ai_route( - $field->baseName, - $pos->{x}, - $pos->{y}, - noSitAuto => 1, - distFromGoal => 1, - attackOnRoute => 0, - isItemTake => 1 - ); - } elsif (timeOut($timeout{ai_take})) { - my %vec; - my $direction; - getVector(\%vec, $item->{pos}, $myPos); - $direction = int(sprintf("%.0f", (360 - vectorToDegree(\%vec)) / 45)) % 8; - $messageSender->sendLook($direction, 0) if ($direction != $char->{look}{body}); - $messageSender->sendTake($item->{ID}); - $timeout{ai_take}{time} = time; + if ($char->{sitting}) { + stand(); + + } elsif ($dist <= 2 && $config{'itemsTakeGreed'} && $char->{skills}{BS_GREED}{lv} >= 1 && !$char->{muted}) { + my $skill = new Skill(handle => 'BS_GREED'); + ai_skillUse2($skill, $char->{skills}{BS_GREED}{lv}, 1, 0, $char, "BS_GREED"); + + } elsif ($dist > 1 && timeOut(AI::args()->{time_route}, $timeout{ai_take_giveup}{timeout})) { + my $pos = $item->{pos}; + AI::args()->{time_route} = time; + ai_route( + $field->baseName, + $pos->{x}, + $pos->{y}, + noSitAuto => 1, + distFromGoal => 1, + attackOnRoute => 0, + isItemTake => 1 + ); + # Walking to the drop is not a failed loot attempt; pause the + # give-up timer until the route finishes and take() becomes active again. + AI::suspend(1); + } else { + return unless (timeOut($timeout{ai_take})); + return unless (actorFinishedMovement($char, $field, 0.1, 1)); + my %vec; + my $direction; + getVector(\%vec, $item->{pos}, $myPos); + $direction = int(sprintf("%.0f", (360 - vectorToDegree(\%vec)) / 45)) % 8; + $messageSender->sendLook($direction, 0) if ($direction != $char->{look}{body}); + $messageSender->sendTake($item->{ID}); + $timeout{ai_take}{time} = time; + } } } } @@ -1217,6 +1347,7 @@ sub processCartGet { } sub processAutoMakeArrow { + return if ($char->{muted}); ####### AUTO MAKE ARROW ####### if ((AI::isIdle() || AI::is(qw/route move autoBuy storageAuto follow sitAuto items_take items_gather/)) && timeOut($AI::Timeouts::autoArrow, 0.2) && $config{autoMakeArrows} && defined binFind(\@skillsID, 'AC_MAKINGARROW') ) { @@ -1843,7 +1974,7 @@ sub processAutoSell { Plugins::callHook('AI_sell_auto'); # Form list of items to sell - my @sellItems; + @sellList = (); for my $item (@{$char->inventory}) { next if ($item->{equipped} || !$item->{sellable}); @@ -1853,15 +1984,15 @@ sub processAutoSell { my %obj; $obj{ID} = $item->{ID}; $obj{amount} = $item->{amount} - $control->{keep}; - push @sellItems, \%obj; + push @sellList, \%obj; } } - if (@sellItems == 0) { + if (@sellList == 0) { $args->{'sentEmptyList'} = 1; } - completeNpcSell(\@sellItems); + completeNpcSell(\@sellList); delete $args->{'sentNpcTalk'}; delete $args->{'sentNpcTalk_time'}; @@ -2150,7 +2281,7 @@ sub processAutoCart { my @getItems; my $max; - if ($config{cartMaxWeight} && $char->cart->{weight} < $config{cartMaxWeight}) { + if ($char->cart->{weight_max} && $char->cart->{weight} < $char->cart->{weight_max}) { for my $invItem (@{$char->inventory}) { next if ($invItem->{broken} && $invItem->{type} == 7); # dont auto-cart add pet eggs in use next if ($invItem->{equipped}); @@ -2875,8 +3006,8 @@ sub processAutoItemUse { ##### AUTO-SKILL USE ##### sub processAutoSkillUse { - if (AI::isIdle() || AI::is(qw(route mapRoute follow sitAuto take items_gather items_take attack teleport) ) - || (AI::action() eq "skill_use" && AI::args()->{tag} eq "attackSkill")) { + return if ($char->{muted}); + if (AI::isIdle() || AI::is(qw(route mapRoute follow sitAuto take items_gather items_take attack teleport) ) || (AI::action() eq "skill_use" && AI::args()->{tag} eq "attackSkill")) { my %self_skill; for (my $i = 0; exists $config{"useSelf_skill_$i"}; $i++) { if ($config{"useSelf_skill_$i"} && checkSelfCondition("useSelf_skill_$i")) { @@ -2938,6 +3069,7 @@ sub processAutoSkillUse { ##### PARTY-SKILL USE ##### sub processPartySkillUse { + return if ($char->{muted}); if (AI::isIdle() || AI::is(qw(route mapRoute follow sitAuto take items_gather items_take attack move))){ my $realMyPos = calcPosFromPathfinding($field, $char); my %party_skill; @@ -3048,6 +3180,7 @@ sub processPartySkillUse { ##### MONSTER SKILL USE ##### sub processMonsterSkillUse { + return if ($char->{muted}); if (AI::isIdle() || AI::is(qw(route mapRoute follow sitAuto take items_gather items_take attack move))) { my $i = 0; my $prefix = "monsterSkill_$i"; @@ -3295,13 +3428,6 @@ sub processAutoAttack { my $target_pos = calcPosition($monster); # TODO: Is there any situation where we should use calcPosFromPathfinding or calcPosFromTime here? next unless ($control->{dist} eq '' || blockDistance($target_pos, $myPos) <= $control->{dist}); - - # TODO: Sometimes we had no LOS to attack mob and dropped it, but now it is following us and attacking us - # which means we now have LOS to is, it we should have a way to delete ai_attack_unfail and ai_attack_failedLOS - # timeouts in these cases. - next unless (timeOut($monster->{attack_failed}, $timeout{ai_attack_unfail}{timeout})); - next unless (timeOut($monster->{attack_failedLOS}, $timeout{ai_attack_failedLOS}{timeout})); - my %hookArgs; $hookArgs{monster} = $monster; $hookArgs{return} = 1; @@ -3454,7 +3580,7 @@ sub processItemsGather { AI::suspend(); stand(); - } elsif (blockDistance($items{$ID}{pos}, $char->{pos}) > 2 && timeOut(AI::args()->{time_route} = time, $timeout{ai_take_giveup}{timeout})) { + } elsif (blockDistance($items{$ID}{pos}, $char->{pos}) > 2) { my $item = $items{$ID}; my $pos = $item->{pos}; AI::args()->{time_route} = time; @@ -3653,17 +3779,17 @@ sub processAvoid { avoidGM_near() if ($config{avoidGM_near} >= 1 && (!$field->isCity || $config{avoidGM_near_inTown})); avoidList_near() if $config{avoidList} >= 1; $timeout{ai_avoidcheck}{time} = time; - } - foreach (@monstersID) { - next unless $_; - my $action = mon_control($monsters{$_}{name},$monsters{$_}{nameID})->{teleport_auto}; - - if ($action == 3) { - warning TF("Disconnecting for 30 secs to avoid %s\n", $monsters{$_}{name}); - relog(30); - } elsif ($action > 3) { - warning TF("Disconnecting for %s secs to avoid %s\n", $action, $monsters{$_}{name}); - relog($action); + foreach (@monstersID) { + next unless $_; + my $action = mon_control($monsters{$_}{name},$monsters{$_}{nameID})->{teleport_auto}; + + if ($action == 3) { + warning TF("Disconnecting for 30 secs to avoid %s\n", $monsters{$_}{name}); + relog(30); + } elsif ($action > 3) { + warning TF("Disconnecting for %s secs to avoid %s\n", $action, $monsters{$_}{name}); + relog($action); + } } } } @@ -3828,7 +3954,7 @@ sub processRepairAuto { return; } - if ($args->{useSkill}) { + if ($args->{useSkill} && !$char->{muted}) { my $handle = repairAutoSkillHandle(); unless ($handle) { error T("Unable to auto repair: no repair skill available.\n"); diff --git a/src/AI/SlaveAttack.pm b/src/AI/SlaveAttack.pm index e118b5b239..7b030e18fd 100644 --- a/src/AI/SlaveAttack.pm +++ b/src/AI/SlaveAttack.pm @@ -490,6 +490,15 @@ sub main { } my $canAttack_fail_string = (($canAttack == -2) ? "No Method" : (($canAttack == -1) ? "No LOS" : (($canAttack == 0) ? "No Range" : "OK"))); + my $future_wait_timeout = $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'}; + my $future_wait_max_time = $future_wait_timeout ? ($future_wait_timeout * 3) : 0; + + if ($youHitTarget) { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time} if exists $args->{ai_attack_allowed_waitForTarget_give_up}; + delete $args->{ai_attack_allowed_waitForTarget_disabled_until_hit}; + } elsif ($canAttack != 0 && $canAttack != -1) { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time} if exists $args->{ai_attack_allowed_waitForTarget_give_up}; + } # Here we check if the monster which we are waiting to get closer to us is in fact close enough # If it is close enough delete the ai_attack_failed_waitForAgressive_give_up keys and loop attack logic @@ -621,16 +630,30 @@ sub main { if ( !$found_action && - $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'} && + $future_wait_timeout && ($canAttack == 0 || $canAttack == -1) && !$hitTarget_when_not_possible ) { - my $futureMonsterPos = calcPosFromPathfinding($field, $target, ($extra_time + $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'})); + my $futureMonsterPos = calcPosFromPathfinding($field, $target, ($extra_time + $future_wait_timeout)); my $futurecanAttack = canAttack($field, $realMyPos, $futureMonsterPos, $config{$slave->{configPrefix}.'attackCanSnipe'}, $args->{attackMethod}{maxDistance}, $config{clientSight}); - if ($futurecanAttack) { - debug TF("[SlaveAttack] %s currently cannot attack, but will be able to in up to [%s secs], waiting. %s (%d %d), target %s (%d %d) [(%d %d) -> (%d %d)])\n", - $slave, $timeout{'ai_attack_allowed_waitForTarget'}{'timeout'}, $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}), 'ai_attack'; - $found_action = 1; + if ($futurecanAttack == 1 && !$args->{ai_attack_allowed_waitForTarget_disabled_until_hit}) { + $args->{ai_attack_allowed_waitForTarget_give_up}{timeout} = $future_wait_max_time if !$args->{ai_attack_allowed_waitForTarget_give_up}{timeout}; + $args->{ai_attack_allowed_waitForTarget_give_up}{time} = time if !$args->{ai_attack_allowed_waitForTarget_give_up}{time}; + + my $waited = time - $args->{ai_attack_allowed_waitForTarget_give_up}{time}; + if (timeOut($args->{ai_attack_allowed_waitForTarget_give_up})) { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time}; + $args->{ai_attack_allowed_waitForTarget_disabled_until_hit} = 1; + warning TF("[SlaveAttack] Predictive wait timed out after [%s/%s secs]; disabling predictive wait until %s hits this target again. currentState %s, attackMethod %s, %s (%d %d), target %s real(%d %d) [(%d %d) -> (%d %d)], future(%d %d), blockDist %d, clientDist %d, maxDistance %d.\n", + $waited, $future_wait_max_time, $slave, $canAttack_fail_string, $args->{attackMethod}{type}, $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}, $futureMonsterPos->{x}, $futureMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}), 'ai_attack'; + } else { + my $remaining = $future_wait_max_time - $waited; + debug TF("[SlaveAttack] Predictive wait active: currentState %s, futureState OK in [%s secs], waited [%s/%s], remaining [%s], attackMethod %s, %s (%d %d), target %s real(%d %d) [(%d %d) -> (%d %d)], future(%d %d), blockDist %d, clientDist %d, maxDistance %d.\n", + $canAttack_fail_string, $future_wait_timeout, $waited, $future_wait_max_time, $remaining, $args->{attackMethod}{type}, $slave, $realMyPos->{x}, $realMyPos->{y}, $target, $realMonsterPos->{x}, $realMonsterPos->{y}, $target->{pos}{x}, $target->{pos}{y}, $target->{pos_to}{x}, $target->{pos_to}{y}, $futureMonsterPos->{x}, $futureMonsterPos->{y}, $realMonsterDist, $clientDist, $args->{attackMethod}{maxDistance}), 'ai_attack'; + $found_action = 1; + } + } else { + delete $args->{ai_attack_allowed_waitForTarget_give_up}{time} if exists $args->{ai_attack_allowed_waitForTarget_give_up}; } } diff --git a/src/Actor.pm b/src/Actor.pm index 9f8aab2c0e..cb6c764b13 100644 --- a/src/Actor.pm +++ b/src/Actor.pm @@ -882,6 +882,7 @@ sub useTeleport { my %tasks = qw(1 Task::Teleport::Random 2 Task::Teleport::Respawn); my $task = $tasks{$level}->new(actor => $self); + message T("Queueing teleport task level $level\n"); $self->queue('teleport', $task); } else { error T("NPC or Teleport in queue, finish and try again\n"); diff --git a/src/Actor/You.pm b/src/Actor/You.pm index c32bbd7067..7af3887308 100644 --- a/src/Actor/You.pm +++ b/src/Actor/You.pm @@ -480,7 +480,7 @@ sub attack { } sub sendSit { - if ($config{'sitTensionRelax'} > 0 && $char->{skills}{LK_TENSIONRELAX}{lv} > 0) { + if ($config{'sitTensionRelax'} > 0 && $char->{skills}{LK_TENSIONRELAX}{lv} > 0 && !$char->{muted}) { my $skill = new Skill(handle => 'LK_TENSIONRELAX'); AI::ai_skillUse2($skill, $char->{skills}{LK_TENSIONRELAX}{lv}, 1, 0, $char, "LK_TENSIONRELAX"); } else { diff --git a/src/FileParsers.pm b/src/FileParsers.pm index d4acc4edcf..ea341dbc0a 100644 --- a/src/FileParsers.pm +++ b/src/FileParsers.pm @@ -2031,10 +2031,11 @@ sub updatePortalLUT { Plugins::callHook('updatePortalLUT', $plugin_args); unless ($plugin_args->{return}) { - open FILE, ">>:utf8", $file; - print FILE "$sourceMap $sourceX $sourceY $destMap $destX $destY\n"; - close FILE; + return replacePortalLUT($file, undef, undef, undef, undef, undef, undef, + "$sourceMap $sourceX $sourceY $destMap $destX $destY"); } + + return $plugin_args->{return}; } #Add: NPC talk Sequence @@ -2045,10 +2046,75 @@ sub updatePortalLUT2 { Plugins::callHook('updatePortalLUT2', $plugin_args); unless ($plugin_args->{return}) { - open FILE, ">>:utf8", $file; - print FILE "$sourceMap $sourceX $sourceY $destMap $destX $destY $steps\n"; - close FILE; + return replacePortalLUT($file, undef, undef, undef, undef, undef, undef, + "$sourceMap $sourceX $sourceY $destMap $destX $destY $steps"); + } + + return $plugin_args->{return}; +} + +sub replacePortalLUT { + my ($file, + $oldSourceMap, $oldSourceX, $oldSourceY, + $oldDestMap, $oldDestX, $oldDestY, + $new_line) = @_; + return 0 unless defined $new_line; + + open my $fh, '<:utf8', $file or return 0; + my @lines = <$fh>; + close $fh; + chomp @lines; + + my $desired_normalized = $new_line; + $desired_normalized =~ s/^\s+|\s+$//g; + + my $old_regex; + if (defined $oldSourceMap && defined $oldSourceX && defined $oldSourceY + && defined $oldDestMap && defined $oldDestX && defined $oldDestY) { + $old_regex = qr/^(\s*)\Q$oldSourceMap\E\s+\Q$oldSourceX\E\s+\Q$oldSourceY\E\s+\Q$oldDestMap\E\s+\Q$oldDestX\E\s+\Q$oldDestY\E(\s.*)?$/; + } + + my @new_lines; + my $insert_at; + my $line_to_insert = $new_line; + for my $line (@lines) { + if ($line =~ /^\s*#/ || $line =~ /^\s*$/) { + push @new_lines, $line; + next; + } + + if ($old_regex && $line =~ $old_regex) { + $insert_at = scalar @new_lines if !defined $insert_at; + my $leading = $1 // ''; + my $trailing = $2 // ''; + $line_to_insert = $leading . $desired_normalized . $trailing if $line_to_insert eq $new_line; + next; + } + + my $normalized_line = $line; + $normalized_line =~ s/^\s+|\s+$//g; + if ($normalized_line eq $desired_normalized) { + $insert_at = scalar @new_lines if !defined $insert_at; + $line_to_insert = $line if $line_to_insert eq $new_line; + next; + } + + push @new_lines, $line; + } + + $insert_at = scalar @new_lines if !defined $insert_at; + splice(@new_lines, $insert_at, 0, $line_to_insert); + + my $original_serialized = join("\n", @lines); + my $new_serialized = join("\n", @new_lines); + if ($original_serialized eq $new_serialized) { + return 2; } + + open my $wh, '>:utf8', $file or return 0; + print {$wh} $new_serialized . "\n"; + close $wh; + return 1; } sub updateNPCLUT { diff --git a/src/Interface/Tk.pm b/src/Interface/Tk.pm index 83dc1156bd..9b223eb4fd 100644 --- a/src/Interface/Tk.pm +++ b/src/Interface/Tk.pm @@ -23,6 +23,7 @@ use strict; use warnings; use Carp qw/carp croak confess/; +use Scalar::Util qw(weaken); use Time::HiRes qw/time usleep/; use Tk; use Tk::ROText; @@ -142,6 +143,11 @@ sub new { return $self; } +sub DESTROY { + my ($self) = @_; + Plugins::delHooks(delete $self->{hooks}) if $self->{hooks}; +} + #### # Interface overrided methods @@ -1596,6 +1602,17 @@ sub followObj { $self->{objc}{$id}[1] = "#CC00CC"; } +sub _weakHook { + my ($self, $callback) = @_; + my $weak_self = $self; + weaken($weak_self); + + return sub { + my $self = $weak_self or return; + $callback->(@_, $self); + }; +} + ### # OpenKore Hooks - update info section @@ -1603,35 +1620,38 @@ sub followObj { sub addHooks { my $self = shift; - Plugins::addHook('mainLoop_pre', \&updateHook, $self); - Plugins::addHook('postloadfiles', \&parsePortals, $self); - Plugins::addHook('packet/actor_exists', \&mapAddActor, $self); - Plugins::addHook('packet/actor_connected', \&mapAddActor, $self); - Plugins::addHook('packet/actor_spawned', \&mapAddActor, $self); - Plugins::addHook('packet/actor_display', \&mapMoveActor, $self); - Plugins::addHook('packet/actor_moved', \&mapMoveActor, $self); - Plugins::addHook('packet/actor_died_or_disappeared', \&mapRemoveActor, $self); - Plugins::addHook('packet/map_change', \&mapChangeUpdateInferface, $self); - Plugins::addHook('packet/map_changed', \&mapChangeUpdateInferface, $self); - Plugins::addHook('packet/map_loaded', \&mapChangeUpdateInferface, $self); - Plugins::addHook('packet/item_exists', \&mapAddActor, $self); - Plugins::addHook('packet/item_appeared', \&mapAddActor, $self); - Plugins::addHook('packet/item_disappeared', \&mapRemoveActor, $self); - Plugins::addHook('packet/arrow_equipped', \&inventoryChanged, $self); - Plugins::addHook('packet/card_merge_status', \&inventoryChanged, $self); - Plugins::addHook('packet/deal_add_you', \&inventoryChanged, $self); - Plugins::addHook('packet/equip_item', \&inventoryChanged, $self); - Plugins::addHook('packet/identify', \&inventoryChanged, $self); - Plugins::addHook('packet/inventory_item_added', \&inventoryChanged, $self); - Plugins::addHook('packet/inventory_item_removed', \&inventoryChanged, $self); - Plugins::addHook('packet_useitem', \&inventoryChanged, $self); - Plugins::addHook('packet/inventory_items_nonstackable', \&inventoryChanged, $self); - Plugins::addHook('packet/inventory_items_stackable', \&inventoryChanged, $self); - Plugins::addHook('packet/item_upgrade', \&inventoryChanged, $self); - Plugins::addHook('packet/unequip_item', \&inventoryChanged, $self); - Plugins::addHook('packet/use_item', \&inventoryChanged, $self); - Plugins::addHook('packet/mail_send', \&inventoryChanged, $self); - Plugins::addHook('packet/item_list_end', \&inventoryChanged, $self); + Plugins::delHooks(delete $self->{hooks}) if $self->{hooks}; + $self->{hooks} = Plugins::addHooks( + ['mainLoop_pre', $self->_weakHook(\&updateHook)], + ['postloadfiles', $self->_weakHook(\&parsePortals)], + ['packet/actor_exists', $self->_weakHook(\&mapAddActor)], + ['packet/actor_connected', $self->_weakHook(\&mapAddActor)], + ['packet/actor_spawned', $self->_weakHook(\&mapAddActor)], + ['packet/actor_display', $self->_weakHook(\&mapMoveActor)], + ['packet/actor_moved', $self->_weakHook(\&mapMoveActor)], + ['packet/actor_died_or_disappeared', $self->_weakHook(\&mapRemoveActor)], + ['packet/map_change', $self->_weakHook(\&mapChangeUpdateInferface)], + ['packet/map_changed', $self->_weakHook(\&mapChangeUpdateInferface)], + ['packet/map_loaded', $self->_weakHook(\&mapChangeUpdateInferface)], + ['packet/item_exists', $self->_weakHook(\&mapAddActor)], + ['packet/item_appeared', $self->_weakHook(\&mapAddActor)], + ['packet/item_disappeared', $self->_weakHook(\&mapRemoveActor)], + ['packet/arrow_equipped', $self->_weakHook(\&inventoryChanged)], + ['packet/card_merge_status', $self->_weakHook(\&inventoryChanged)], + ['packet/deal_add_you', $self->_weakHook(\&inventoryChanged)], + ['packet/equip_item', $self->_weakHook(\&inventoryChanged)], + ['packet/identify', $self->_weakHook(\&inventoryChanged)], + ['packet/inventory_item_added', $self->_weakHook(\&inventoryChanged)], + ['packet/inventory_item_removed', $self->_weakHook(\&inventoryChanged)], + ['packet_useitem', $self->_weakHook(\&inventoryChanged)], + ['packet/inventory_items_nonstackable', $self->_weakHook(\&inventoryChanged)], + ['packet/inventory_items_stackable', $self->_weakHook(\&inventoryChanged)], + ['packet/item_upgrade', $self->_weakHook(\&inventoryChanged)], + ['packet/unequip_item', $self->_weakHook(\&inventoryChanged)], + ['packet/use_item', $self->_weakHook(\&inventoryChanged)], + ['packet/mail_send', $self->_weakHook(\&inventoryChanged)], + ['packet/item_list_end', $self->_weakHook(\&inventoryChanged)], + ); } sub mapAddActor { diff --git a/src/InventoryList/Inventory.pm b/src/InventoryList/Inventory.pm index 6f4bc01958..b9bd907812 100644 --- a/src/InventoryList/Inventory.pm +++ b/src/InventoryList/Inventory.pm @@ -1,6 +1,7 @@ package InventoryList::Inventory; use strict; +use Scalar::Util qw(weaken); use Globals; use InventoryList; use base qw(InventoryList); @@ -13,8 +14,13 @@ use constant { sub new { my ($class) = @_; my $self = $class->SUPER::new; + my $weak_self = $self; + weaken($weak_self); $self->{hooks} = Plugins::addHooks ( - ['packet/stat_info2', sub { $self->onStatInfo2; }] + ['packet/stat_info2', sub { + my $self = $weak_self or return; + $self->onStatInfo2; + }] ); #Here we use packet/stat_info2 because it was the only safe hook I (henrybk) found for this function, both 'inventory_items_stackable' and 'inventory_items_nonstackable' are @@ -23,6 +29,11 @@ sub new { return $self; } +sub DESTROY { + my ($self) = @_; + Plugins::delHooks(delete $self->{hooks}) if $self->{hooks}; +} + sub isReady { my ($self) = @_; return $self->{state}; diff --git a/src/Log.pm b/src/Log.pm index 08870e3adc..47b59aeecb 100644 --- a/src/Log.pm +++ b/src/Log.pm @@ -182,8 +182,6 @@ sub processMsg { my $currentVerbosity = shift; my $consoleVar = shift; my $files = shift; - my (undef, undef, undef, $near) = caller(2); - my (undef, undef, undef, $far) = caller(3); $currentVerbosity = 1 if ($currentVerbosity eq ""); @@ -249,9 +247,13 @@ sub processMsg { } # Call hooks - foreach (@hooks) { - next if (!defined($_)); - $_->{'func'}->($type, $domain, $level, $currentVerbosity, $message, $_->{'user_data'}, $near, $far); + if (@hooks) { + my (undef, undef, undef, $near) = caller(2); + my (undef, undef, undef, $far) = caller(3); + foreach (@hooks) { + next if (!defined($_)); + $_->{'func'}->($type, $domain, $level, $currentVerbosity, $message, $_->{'user_data'}, $near, $far); + } } } @@ -272,8 +274,7 @@ sub processMsg { sub message { my ($message, $domain, $level) = @_; $domain ||= "console"; - $level = 5 if existsInList($config{squelchDomains}, $domain); - $level = 0 if existsInList($config{verboseDomains}, $domain); + $level = setLevel($domain, $level); return processMsg("message", # type $message, $domain, @@ -292,8 +293,7 @@ sub message { sub warning { my ($message, $domain, $level) = @_; $domain ||= "console"; - $level = 5 if existsInList($config{squelchDomains}, $domain); - $level = 0 if existsInList($config{verboseDomains}, $domain); + $level = setLevel($domain, $level); return processMsg("warning", $message, $domain, @@ -324,6 +324,7 @@ sub warning { sub error { my ($message, $domain, $level) = @_; $domain ||= "console"; + $level = setLevel($domain, $level); return processMsg("error", $message, $domain, @@ -342,9 +343,7 @@ sub error { sub debug { my ($message, $domain, $level) = @_; $domain ||= "console"; - $level = 1 if (!defined $level); - $level = 0 if (existsInList($config{debugDomains}, $_[1])); - $level = 5 if (existsInList($config{squelchDomains}, $_[1])); + $level = setLevel($domain, $level); return processMsg("debug", $message, $domain, @@ -354,6 +353,18 @@ sub debug { \%debugFiles); } +sub setLevel { + my ($domain, $level) = @_; + if (existsInList($config{squelchDomains}, $domain)) { + $level = 5; + } elsif (existsInList($config{debugDomains}, $domain)) { + $level = 0; + } elsif (!defined $level) { + $level = 1; + } + return $level; +} + ## # Log::addHook(r_func, [user_data]) diff --git a/src/Misc.pm b/src/Misc.pm index 51d82abe9b..e9b44e17f7 100644 --- a/src/Misc.pm +++ b/src/Misc.pm @@ -940,6 +940,67 @@ sub objectInsideCasting { return 0; } +sub actorIsBeingCastedOn { + my ($target, $skills) = @_; + return 0 unless $target && defined $target->{ID} && defined $skills; + + my @skills = grep { $_ ne '' } split / *, */, $skills; + return 0 unless @skills; + + foreach my $caster ($char, @$playersList, @$monstersList, @$npcsList, @$slavesList, @$elementalsList) { + next unless $caster && exists $caster->{casting} && defined $caster->{casting} && $caster->{casting}; + + my $cast = $caster->{casting}; + my $targetID = defined $cast->{targetID} ? $cast->{targetID} : ($cast->{target} && $cast->{target}{ID}); + next unless defined $targetID && $targetID eq $target->{ID}; + + return 1 if castMatchesAnySkill($cast, \@skills); + } + + return 0; +} + +sub nearPartyMemberIsCasting { + my ($skills) = @_; + return 0 unless defined $skills; + return 0 unless $char->{party}{joined}; + + my @skills = grep { $_ ne '' } split / *, */, $skills; + return 0 unless @skills; + + foreach my $member (@$playersList) { + next unless $member && defined $member->{ID}; + next unless $char->{party}{users}{$member->{ID}}; + next unless exists $member->{casting} && defined $member->{casting} && $member->{casting}; + + return 1 if castMatchesAnySkill($member->{casting}, \@skills); + } + + return 0; +} + +sub castMatchesAnySkill { + my ($cast, $skills) = @_; + return 0 unless $cast && $cast->{skill} && $skills && @$skills; + + my $castSkill = $cast->{skill}; + my $castIDN = $castSkill->getIDN(); + my $castHandle = $castSkill->getHandle(); + my $castName = $castSkill->getName(); + + foreach my $skillName (@$skills) { + return 1 if defined $castHandle && $castHandle eq $skillName; + return 1 if defined $castName && lc($castName) eq lc($skillName); + return 1 if defined $castIDN && $skillName =~ /^\d+$/ && $castIDN == $skillName; + + my $requestedSkill = Skill->new(auto => $skillName); + my $requestedIDN = $requestedSkill->getIDN(); + return 1 if defined $castIDN && defined $requestedIDN && $castIDN == $requestedIDN; + } + + return 0; +} + ## # objectIsMovingTowards(object1, object2, [max_variance]) # @@ -4323,6 +4384,7 @@ sub canUseTeleport { my $randomTeleportBlocked = isRandomTeleportBlockedOnMap($current_map); my $teleportSkillBlocked = isTeleportSkillBlockedOnMap($current_map); my $returnTeleportBlocked = isReturnTeleportBlockedOnMap($current_map); + my $teleportSkillSuppressed = _isTeleportSkillSuppressedByStatus(); my $item; if ($use_lvl == 1) { @@ -4353,11 +4415,11 @@ sub canUseTeleport { $itemAvailable = 1 if (!$cooldownActive && $equipRequirementSatisfied); } } + return $itemAvailable if $char->{'muted'}; my $chatAvailable = (!$returnTeleportBlocked && $config{saveMap_warpChatCommand}) ? 1 : 0; - my $equipAvailable = (!$teleportSkillBlocked && Actor::Item::scanConfigAndCheck('teleportAuto_equip')) ? 1 : 0; - my $skill_level = ($char->{skills}{AL_TELEPORT}{lv}) ? $char->{skills}{AL_TELEPORT}{lv} : 0; - my $skillAvailable = (!$teleportSkillBlocked && $skill_level >= $use_lvl) ? 1 : 0; + my $equipAvailable = (!$teleportSkillBlocked && !$teleportSkillSuppressed && Actor::Item::scanConfigAndCheck('teleportAuto_equip')) ? 1 : 0; + my $skillAvailable = (!$teleportSkillBlocked && _canUseTeleportSkillAtLevel($use_lvl)) ? 1 : 0; return 1 if ($itemAvailable || $chatAvailable || $equipAvailable || $skillAvailable); return 0; @@ -4407,9 +4469,30 @@ sub canUseTeleport { return 1 if(Actor::Item::scanConfigAndCheck('teleportAuto_equip')); # 4 - check for skill + return 1 if _canUseTeleportSkillAtLevel($use_lvl); + + return 0; +} + +sub _canUseTeleportSkillAtLevel { + my ($use_lvl) = @_; + return 0 unless $char; + return 0 if _isTeleportSkillSuppressedByStatus(); + my $skill_level = ($char->{skills}{AL_TELEPORT}{lv}) ? $char->{skills}{AL_TELEPORT}{lv} : 0; - return 1 if($skill_level >= $use_lvl); + return 0 if $skill_level < $use_lvl; + + my $skill = Skill->new(handle => 'AL_TELEPORT'); + my $sp_cost = $skill->getSP($use_lvl); + return 1 unless defined $sp_cost; + return ($char->{sp} // 0) >= $sp_cost; +} + +sub _isTeleportSkillSuppressedByStatus { + return 0 unless $char; + return 1 if $char->{'muted'}; + return 1 if $char->statusActive('HEALTHSTATE_SILENCE, EFST_HEALTHSTATE_SILENCE'); return 0; } @@ -4542,6 +4625,28 @@ sub _targetWillLeaveClientSightSoon { return 0; } +# TODO: Sometimes we had no LOS to attack mob and dropped it, but now it is following us and attacking us +# which means we now have LOS to is, it we should have a way to delete ai_attack_unfail and ai_attack_failedLOS +# timeouts in these cases. +sub _targetRecentlyFailedAttack { + my ($actor, $target) = @_; + + return 0 unless ($actor && $target); + + my $failed_timeout_key = ( + exists $actor->{ai_attack_failed_timeout} + && defined $actor->{ai_attack_failed_timeout} + && $actor->{ai_attack_failed_timeout} ne '' + ) + ? $actor->{ai_attack_failed_timeout} + : 'attack_failed'; + + return 1 if (!timeOut($target->{attack_failedLOS}, $timeout{ai_attack_failedLOS}{timeout})); + return 1 if (!timeOut($target->{$failed_timeout_key}, $timeout{ai_attack_unfail}{timeout})); + + return 0; +} + ## # getBestTarget(possibleTargets, attackCheckLOS, $attackCanSnipe, $actor, $configPrefix) # possibleTargets: reference to an array of monsters' IDs @@ -4580,6 +4685,8 @@ sub getBestTarget { foreach (@{$possibleTargets}) { my $monster = $monsters{$_}; + next if _targetRecentlyFailedAttack($actor, $monster); + # TODO: Is there any situation where we should use calcPosFromPathfinding or calcPosFromTime here? my $targetPos = calcPosFromPathfinding($field, $monster); @@ -4606,6 +4713,9 @@ sub getBestTarget { push(@noLOSMonsters_pos, $targetPos); next; } + + my $blockDist = blockDistance($actorPos, $targetPos); + next if ($blockDist > $config{attackRouteMaxPathDistance}); my $dist = adjustedBlockDistance($actorPos, $targetPos); my $priority = monsterPriority($monster->{name}, $monster->{nameID}); @@ -4647,6 +4757,8 @@ sub getBestTarget { } my $dist = scalar @{$solution}; + + next if ($dist > $config{attackRouteMaxPathDistance}); my $priority = monsterPriority($monster->{name}, $monster->{nameID}); @@ -5380,6 +5492,8 @@ sub checkSelfCondition { return 0 if ($config{$prefix."_whenIdle"} && !AI::isIdle()); return 0 if ($config{$prefix."_whenNotIdle"} && AI::isIdle()); + + my $realMyPos = calcPosFromPathfinding($field, $char); # TODO: Is there any situation where we should use calcPosFromPathfinding or calcPosFromTime here in these checks? @@ -5611,21 +5725,17 @@ sub checkSelfCondition { if ($config{$prefix . "_notWhileSitting"} > 0) { return 0 if ($char->{sitting}); } if ($config{$prefix . "_notWhileCasting"} > 0) { return 0 if (exists $char->{casting}); } if ($config{$prefix . "_whileCasting"} > 0) { return 0 unless (exists $char->{casting}); } + if ($config{$prefix . "_notWhileBeingCasted"}) { return 0 if actorIsBeingCastedOn($char, $config{$prefix . "_notWhileBeingCasted"}); } + if ($config{$prefix . "_whileBeingCasted"}) { return 0 unless actorIsBeingCastedOn($char, $config{$prefix . "_whileBeingCasted"}); } + if ($config{$prefix . "_whenNoNearPartyMemberCasting"}) { return 0 if nearPartyMemberIsCasting($config{$prefix . "_whenNoNearPartyMemberCasting"}); } + if ($config{$prefix . "_whenNearPartyMemberCasting"}) { return 0 unless nearPartyMemberIsCasting($config{$prefix . "_whenNearPartyMemberCasting"}); } if ($config{$prefix . "_notInTown"} > 0) { return 0 if ($field->isCity); } if ($config{$prefix . "_inTown"} > 0) { return 0 unless ($field->isCity); } - if (defined $config{$prefix . "_monstersCount"}) { - my $nowMonsters = $monstersList->size(); - if ($nowMonsters > 0 && $config{$prefix . "_notMonsters"}) { - for my $monster (@$monstersList) { - $nowMonsters-- if (existsInList($config{$prefix . "_notMonsters"}, $monster->{name}) || - existsInList($config{$prefix . "_notMonsters"}, $monster->{nameID}) || - ($config{$prefix."_monstersCountDist"} && !inRange(blockDistance(calcPosition($char), calcPosition($monster)), $config{$prefix."_monstersCountDist"})) - ); - } - } - return 0 unless (inRange($nowMonsters, $config{$prefix . "_monstersCount"})); - } - if ($config{$prefix . "_monsters"} && !($prefix =~ /skillSlot/i) && !($prefix =~ /ComboSlot/i)) { + + my $check_not_monsters = defined $config{$prefix . "_notMonsters"} && !($prefix =~ /skillSlot/i) && !($prefix =~ /ComboSlot/i); + my $check_monsters = defined $config{$prefix . "_monsters"} && !($prefix =~ /skillSlot/i) && !($prefix =~ /ComboSlot/i); + + if ($check_monsters) { my $exists; foreach (ai_getAggressives()) { if (existsInList($config{$prefix . "_monsters"}, $monsters{$_}->name) || @@ -5637,6 +5747,38 @@ sub checkSelfCondition { return 0 unless $exists; } + if ($check_not_monsters) { + my $exists; + foreach (ai_getAggressives()) { + if (existsInList($config{$prefix . "_notMonsters"}, $monsters{$_}->name) || + existsInList($config{$prefix . "_notMonsters"}, $monsters{$_}->{nameID})) { + return 0; + } + } + } + + if (defined $config{$prefix . "_monstersCount"}) { + my $max_dist = defined $config{$prefix . "_monstersCountDist"} ? $config{$prefix . "_monstersCountDist"} : 0; + + my $found = 0; + for my $monster (@$monstersList) { + if ( $check_not_monsters && (existsInList($config{$prefix . "_notMonsters"}, $monster->{name}) || existsInList($config{$prefix . "_notMonsters"}, $monster->{nameID}))) { + next; + } + if ( $check_monsters && !(existsInList($config{$prefix . "_monsters"}, $monster->{name}) || existsInList($config{$prefix . "_monsters"}, $monster->{nameID}))) { + next; + } + if ($max_dist) { + my $realMonsterPos = calcPosFromPathfinding($field, $monster); + my $dist = blockDistance($realMyPos, $realMonsterPos); + next if ($dist > $max_dist); + } + + $found++; + } + return 0 unless (inRange($found, $config{$prefix . "_monstersCount"})); + } + if ($config{$prefix . "_defendMonsters"}) { my $exists; foreach (ai_getMonstersAttacking($accountID)) { @@ -5649,16 +5791,6 @@ sub checkSelfCondition { return 0 unless $exists; } - if ($config{$prefix . "_notMonsters"} && !($prefix =~ /skillSlot/i) && !($prefix =~ /ComboSlot/i)) { - my $exists; - foreach (ai_getAggressives()) { - if (existsInList($config{$prefix . "_notMonsters"}, $monsters{$_}->name) || - existsInList($config{$prefix . "_notMonsters"}, $monsters{$_}->{nameID})) { - return 0; - } - } - } - if ($config{$prefix."_inInventory"}) { return 0 if (!$char->inventory->isReady()); foreach my $input (split / *, */, $config{$prefix."_inInventory"}) { @@ -5689,6 +5821,22 @@ sub checkSelfCondition { } } + if ($config{$prefix."_inCartID"}) { + return 0 if (!$char->cart->isReady()); + foreach my $input (split / *, */, $config{$prefix."_inCartID"}) { + my ($itemID,$count) = $input =~ /(.*?)(?:\s+([><]=? *\d+))?$/; + $count = '>0' if $count eq ''; + my $item = $char->cart->getByNameID($itemID); + return 0 if !inRange(!$item ? 0 : $item->{amount}, $count); + } + } + + if ($config{$prefix."_cartActive"}) { + my $wanted = ($config{$prefix."_cartActive"} ? 1 : 0); + my $is_active = ($char->cart->isReady()) ? 1 : 0; + return 0 if ($wanted != $is_active); + } + if ($config{$prefix."_whenGround"}) { return 0 unless whenGroundStatus(calcPosition($char), $config{$prefix."_whenGround"}); } @@ -5893,6 +6041,8 @@ sub checkPlayerCondition { return 0 if $player->statusActive($config{$prefix . "_whenStatusInactive"}); } if ($config{$prefix . "_notWhileSitting"} > 0) { return 0 if ($player->{sitting}); } + if ($config{$prefix . "_notWhileBeingCasted"}) { return 0 if actorIsBeingCastedOn($player, $config{$prefix . "_notWhileBeingCasted"}); } + if ($config{$prefix . "_whileBeingCasted"}) { return 0 unless actorIsBeingCastedOn($player, $config{$prefix . "_whileBeingCasted"}); } # TODO: Optimize this if ($config{$prefix . "_hp"}) { @@ -6026,9 +6176,9 @@ sub checkPlayerCondition { sub checkMonsterCondition { my ($prefix, $monster) = @_; - - # TODO: Is there any situation where we should use calcPosFromPathfinding or calcPosFromTime in these checks? + my $realMyPos = calcPosFromPathfinding($field, $char); + if ($config{$prefix . "_hp"}) { if($config{$prefix . "_hp"} =~ /(\d+)%$/) { if($monster->{hp} && $monster->{hp_max}) { @@ -6065,16 +6215,24 @@ sub checkMonsterCondition { if ($config{$prefix . "_whenStatusInactive"}) { return 0 if $monster->statusActive($config{$prefix . "_whenStatusInactive"}); } + if ($config{$prefix . "_notWhileBeingCasted"}) { + return 0 if actorIsBeingCastedOn($monster, $config{$prefix . "_notWhileBeingCasted"}); + } + if ($config{$prefix . "_whileBeingCasted"}) { + return 0 unless actorIsBeingCastedOn($monster, $config{$prefix . "_whileBeingCasted"}); + } + + my $realMonsterPos = calcPosFromPathfinding($field, $monster); if ($config{$prefix."_whenGround"}) { - return 0 unless whenGroundStatus(calcPosition($monster), $config{$prefix."_whenGround"}); + return 0 unless whenGroundStatus($realMonsterPos, $config{$prefix."_whenGround"}); } if ($config{$prefix."_whenNotGround"}) { - return 0 if whenGroundStatus(calcPosition($monster), $config{$prefix."_whenNotGround"}); + return 0 if whenGroundStatus($realMonsterPos, $config{$prefix."_whenNotGround"}); } if ($config{$prefix."_dist"}) { - return 0 unless inRange(blockDistance(calcPosition($char), calcPosition($monster)), $config{$prefix."_dist"}); + return 0 unless inRange(blockDistance($realMyPos, $realMonsterPos), $config{$prefix."_dist"}); } if ($config{$prefix."_deltaHp"}){ @@ -7022,7 +7180,7 @@ sub print_callers { line => $info[2], sub_name => $sub_name, }; - last if @callers >= 7; + last if @callers >= 15; $level++; } diff --git a/src/Network/Receive.pm b/src/Network/Receive.pm index 970a82261b..ca6ac3c12e 100644 --- a/src/Network/Receive.pm +++ b/src/Network/Receive.pm @@ -104,7 +104,8 @@ our %EXPORT_TAGS = ( VAR_SP_PATK VAR_SP_SMATK VAR_SP_RES VAR_SP_MRES VAR_SP_HPLUS VAR_SP_CRATE VAR_SP_TRAITPOINT VAR_SP_AP VAR_SP_MAXAP VAR_SP_UPOW VAR_SP_USTA VAR_SP_UWIS VAR_SP_USPL VAR_SP_UCON VAR_SP_UCRT)], party_invite => [qw(ANSWER_ALREADY_OTHERGROUPM ANSWER_JOIN_REFUSE ANSWER_JOIN_ACCEPT ANSWER_MEMBER_OVERSIZE ANSWER_DUPLICATE - ANSWER_JOINMSG_REFUSE ANSWER_UNKNOWN_ERROR ANSWER_UNKNOWN_CHARACTER ANSWER_INVALID_MAPPROPERTY)], + ANSWER_JOINMSG_REFUSE ANSWER_UNKNOWN_ERROR ANSWER_UNKNOWN_CHARACTER ANSWER_INVALID_MAPPROPERTY + ANSWER_INVALID_MAPPROPERTY_ME ANSWER_MEMORIALDUNGEON ANSWER_LEVEL_MISMATCH)], party_leave => [qw(GROUPMEMBER_DELETE_LEAVE GROUPMEMBER_DELETE_EXPEL)], exp_origin => [qw(EXP_FROM_BATTLE EXP_FROM_QUEST)], ); @@ -464,6 +465,9 @@ use constant { ANSWER_UNKNOWN_ERROR => 0x6, ANSWER_UNKNOWN_CHARACTER => 0x7, ANSWER_INVALID_MAPPROPERTY => 0x8, + ANSWER_INVALID_MAPPROPERTY_ME => 0x9, + ANSWER_MEMORIALDUNGEON => 0xA, + ANSWER_LEVEL_MISMATCH => 0xB, }; # party leave result @@ -910,7 +914,7 @@ sub received_characters { } elsif ($config{pauseCharLogin}) { return if ($config{XKore} eq 1 || $config{XKore} eq 3); - if (!defined $timeout{'char_login_pause'}{'timeout'}) { + if (!defined $timeout{'char_login_pause'}{'timeout'} || !$timeout{'char_login_pause'}{'timeout'}) { $timeout{'char_login_pause'}{'timeout'} = $config{pauseCharLogin}; } $timeout{'char_login_pause'}{'time'} = time; @@ -3435,10 +3439,9 @@ sub show_script { sub skill_post_delay { my ($self, $args) = @_; - my $skillName = (new Skill(idn => $args->{ID}))->getName; - my $status = defined $statusName{'EFST_DELAY'} ? $statusName{'EFST_DELAY'} : 'Delay'; + my $skillHandleName = (new Skill(idn => $args->{ID}))->getHandle(); - $char->setStatus($skillName." ".$status, 1, $args->{time}); + $char->setStatus($skillHandleName."_DELAY", 1, $args->{time}); } # Skill cooldown display icon List. @@ -3466,10 +3469,8 @@ sub skill_post_delaylist { for (my $i = 0; $i < length($args->{skill_list}); $i += $skill_post_delay_info->{len}) { my $skill; @{$skill}{@{$skill_post_delay_info->{keys}}} = unpack($skill_post_delay_info->{types}, substr($args->{skill_list}, $i, $skill_post_delay_info->{len})); - $skill->{name} = (new Skill(idn => $skill->{ID}))->getName; - my $status = defined $statusName{'EFST_DELAY'} ? $statusName{'EFST_DELAY'} : 'Delay'; - - $char->setStatus($skill->{name}." ".$status, 1, $skill->{remain_time}); + $skill->{handle} = (new Skill(idn => $skill->{ID}))->getHandle(); + $char->setStatus($skill->{handle}."_DELAY", 1, $skill->{remain_time}); } } @@ -3770,6 +3771,7 @@ sub inventory_item_added { if (AI::state() == AI::AUTO()) { # Auto-drop item + # TODO: We should move this to corelogic if (pickupitems($item->{name}, $item->{nameID}) == -1 && !AI::inQueue('storageAuto', 'buyAuto')) { $messageSender->sendDrop($item->{ID}, $amount); message TF("Auto-dropping item: %s (%d) x %d\n", $item->{name}, $item->{binID}, $amount), "drop"; @@ -3943,7 +3945,14 @@ sub shop_sold_long { sub vending_start { my ($self, $args) = @_; - my $item_pack = $self->{vender_items_list_item_pack_self} || $self->{vender_items_list_item_pack} || 'V v2 C v C3 a8'; + my $modern_self_shop = $args->{switch} eq '0B40' && !$self->{vender_items_list_item_pack_self}; + my $item_pack = $self->{vender_items_list_item_pack_self} + || ($modern_self_shop ? 'V v2 C V C C a8 a25 C C' : undef) + || $self->{vender_items_list_item_pack} + || 'V v2 C v C3 a8'; + my @item_keys = $modern_self_shop + ? qw(price number quantity type nameID identified broken cards options upgrade grade) + : qw(price number quantity type nameID identified broken upgrade cards options location sprite_id); my $item_len = length pack $item_pack; my $item_list_len = length $args->{itemList}; #started a shop. @@ -3958,7 +3967,7 @@ sub vending_start { T("# Name Type Price Amount\n"); for (my $i = 0; $i < $item_list_len; $i += $item_len) { my $item = {}; - @$item{qw( price number quantity type nameID identified broken upgrade cards options location sprite_id)} = unpack $item_pack, substr $args->{itemList}, $i, $item_len; + @$item{@item_keys} = unpack $item_pack, substr $args->{itemList}, $i, $item_len; $item->{name} = itemName($item); $articles[delete $item->{number}] = $item; $articles++; @@ -4569,12 +4578,20 @@ sub sprite_change { my ($self, $args) = @_; my ($ID, $type, $value1, $value2) = @{$args}{qw(ID type value1 value2)}; - my $player = ($ID ne $accountID)? $playersList->getByID($ID) : $char; + my $player = ($ID ne $accountID) ? $playersList->getByID($ID) : $char; return unless $player; if ($type == 0) { - $player->{jobID} = $value1; - message TF("%s changed Job to: %s\n", $player, $jobs_lut{$value1}), "parseMsg_statuslook"; + if ($ID eq $accountID) { + my $old_job = $player->{jobID}; + $player->{jobID} = $value1; + message TF("Your job changed from %s to: %s\n", $jobs_lut{$old_job}, $jobs_lut{$value1}), "parseMsg_statuslook"; + Plugins::callHook('job_changed', {old_job => $old_job, new_job => $value1}); + + } else { + $player->{jobID} = $value1; + message TF("%s changed Job to: %s\n", $player, $jobs_lut{$value1}), "parseMsg_statuslook"; + } } elsif ($type == 2) { if ($value1 ne $player->{weapon}) { @@ -8526,11 +8543,16 @@ sub party_join { $char->{party}{itemDivision} = $info->{'item_share'}; } -# TODO: store this state sub party_allow_invite { my ($self, $args) = @_; + my $deny_invites = $args->{type} ? 1 : 0; + my $previous = $char->{denyPartyInvites}; - if ($args->{type}) { + $char->{denyPartyInvites} = $deny_invites; + + return if !defined $previous || $previous == $deny_invites; + + if ($deny_invites) { message T("Not allowed other player invite to Party\n"), "party", 1; } else { message T("Allowed other player invite to Party\n"), "party", 1; @@ -8564,11 +8586,14 @@ sub party_chat { sub party_exp { my ($self, $args) = @_; - $char->{party}{share} = $args->{type}; # Always will be there, in 0101 also in 07D8 if ($args->{type} == 0) { + $char->{party}{share} = $args->{type}; message T("Party EXP set to Individual Take\n"), "party", 1; } elsif ($args->{type} == 1) { + $char->{party}{share} = $args->{type}; message T("Party EXP set to Even Share\n"), "party", 1; + } elsif ($args->{type} == 2) { + message T("Party EXP sharing cannot be changed right now\n"), "party", 1; } else { error T("Error setting party option\n"); } @@ -8642,13 +8667,21 @@ sub party_invite_result { } elsif ($args->{type} == ANSWER_DUPLICATE) { message TF("Join request failed: same account of %s allready joined the party.\n", $name), "info"; } elsif ($args->{type} == ANSWER_JOINMSG_REFUSE) { - message TF("Join request failed: ANSWER_JOINMSG_REFUSE.\n", $name), "info"; + message TF("Join request failed: %s blocked party invites.\n", $name), "info"; } elsif ($args->{type} == ANSWER_UNKNOWN_ERROR) { message TF("Join request failed: unknown error.\n", $name), "info"; } elsif ($args->{type} == ANSWER_UNKNOWN_CHARACTER) { message TF("Join request failed: the character is not currently online or does not exist.\n", $name), "info"; } elsif ($args->{type} == ANSWER_INVALID_MAPPROPERTY) { - message TF("Join request failed: ANSWER_INVALID_MAPPROPERTY.\n", $name), "info"; + message TF("Join request failed: %s is on a map where party invites are restricted.\n", $name), "info"; + } elsif ($args->{type} == ANSWER_INVALID_MAPPROPERTY_ME) { + message T("Join request failed: you cannot join a party on this map.\n"), "info"; + } elsif ($args->{type} == ANSWER_MEMORIALDUNGEON) { + message T("Join request failed: party invites and removals are blocked in this memorial dungeon.\n"), "info"; + } elsif ($args->{type} == ANSWER_LEVEL_MISMATCH) { + message TF("Join request failed: %s does not meet the party level requirements.\n", $name), "info"; + } else { + message TF("Join request failed: unknown result %d.\n", $args->{type}), "info"; } } @@ -8681,10 +8714,18 @@ sub party_location { my $ID = $args->{ID}; if ($char->{party}{users}{$ID}) { + $char->{party}{users}{$ID}{online} = 1; + + if ($args->{x} >= 65535 || $args->{y} >= 65535) { + delete $char->{party}{users}{$ID}{pos}{x}; + delete $char->{party}{users}{$ID}{pos}{y}; + debug "Party member location unavailable: $char->{party}{users}{$ID}{name} - $args->{x}, $args->{y}\n", "parseMsg", 2; + return; + } + $char->{party}{users}{$ID}{pos}{x} = $args->{x}; $char->{party}{users}{$ID}{pos}{y} = $args->{y}; - $char->{party}{users}{$ID}{online} = 1; - debug "Party member location: $char->{party}{users}{$ID}{name} - $args->{x}, $args->{y}\n", "parseMsg"; + debug "Party member location: $char->{party}{users}{$ID}{name} - $args->{x}, $args->{y}\n", "parseMsg", 2; } } sub party_organize_result { @@ -8748,10 +8789,20 @@ sub party_users_info { } $char->{party}{name} = bytesToString($args->{party_name}); + my $player_info_len = $player_info->{len}; + my $payload_len = length($args->{playerInfo}); + my $tail_len = $payload_len % $player_info_len; + my $members_len = $payload_len - $tail_len; - for (my $i = 0; $i < length($args->{playerInfo}); $i += $player_info->{len}) { - # in 0a43 lasts bytes: { .B .B .L } - next if (length($args->{playerInfo}) - $i == 6); + if ($tail_len == 6) { + my ($item_pickup, $item_share) = unpack('C2', substr($args->{playerInfo}, $members_len, 2)); + $char->{party}{itemPickup} = $item_pickup; + $char->{party}{itemDivision} = $item_share; + } elsif ($tail_len != 0) { + debug "Unexpected trailing data in party_users_info: $tail_len bytes\n", "parseMsg"; + } + + for (my $i = 0; $i < $members_len; $i += $player_info_len) { my $ID = substr($args->{playerInfo}, $i, 4); @@ -8760,7 +8811,7 @@ sub party_users_info { } $char->{party}{users}{$ID} = new Actor::Party(); - @{$char->{party}{users}{$ID}}{@{$player_info->{keys}}} = unpack($player_info->{types}, substr($args->{playerInfo}, $i, $player_info->{len})); + @{$char->{party}{users}{$ID}}{@{$player_info->{keys}}} = unpack($player_info->{types}, substr($args->{playerInfo}, $i, $player_info_len)); $char->{party}{users}{$ID}{name} = bytesToString($char->{party}{users}{$ID}{name}); $char->{party}{users}{$ID}{admin} = !$char->{party}{users}{$ID}{admin}; $char->{party}{users}{$ID}{online} = !$char->{party}{users}{$ID}{online}; @@ -9736,7 +9787,7 @@ sub partylv_info { my ($self, $args) = @_; my $ID = $args->{ID}; if ($char->{party}{users}{$ID}) { - $char->{party}{users}{$ID}{job} = $args->{job}; + $char->{party}{users}{$ID}{jobID} = $args->{job}; $char->{party}{users}{$ID}{lv} = $args->{lv}; } } @@ -9838,12 +9889,15 @@ sub sell_result { if ($args->{fail}) { error T("Sell failed.\n"); } else { - message TF("Sold %s items.\n", @sellList.""), "success"; + my $itemCount = scalar @sellList; + message TF("Sold %d items.\n", $itemCount), "success" if ($itemCount > 0); message T("Sell completed.\n"), "success"; } @sellList = (); - if (AI::is("sellAuto")) { - AI::args()->{recv_sell_packet} = 1; + + my $sellAutoIndex = AI::findAction("sellAuto"); + if (defined $sellAutoIndex) { + AI::args($sellAutoIndex)->{recv_sell_packet} = 1; } } @@ -11825,6 +11879,7 @@ sub skill_cast { my $skill = new Skill(idn => $skillID); $source->{casting} = { skill => $skill, + targetID => $targetID, target => $target, x => $x, y => $y, @@ -11876,6 +11931,7 @@ sub skill_cast { Misc::checkValidity("skill_cast part 3"); + # TODO: we should probably move this somewhere else (Corelogic probably), since it is not really related to skill_cast, but it is more related to the reaction to the skill cast # Skill Cancel my $monster = $monstersList->getByID($sourceID); my $control; @@ -12793,28 +12849,41 @@ sub parse_notify_accessible_mapname { my $mapList = { len => 20, types => 'V Z16', - keys => [qw(unknown map_name)], + keys => [qw(status map_name)], }; @{$args->{map_list}} = map { my %map; @map{@{$mapList->{keys}}} = unpack($mapList->{types}, $_); + $map{map_name} =~ s/\.gat\z//i; \%map; } unpack "(a$mapList->{len})*", $args->{mapList}; } sub notify_accessible_mapname { my ($self, $args) = @_; - my $map_index = 0; + my $map_index; + my $fallback_index; + my $save_map = defined $config{saveMap} ? lc $config{saveMap} : undef; + $save_map =~ s/\.gat\z//i if defined $save_map; foreach my $i (0 .. $#{$args->{map_list}}) { my $map = $args->{map_list}[$i]; - error("[notify_accessible_mapname] unknown = $map->{unknown}, name = $map->{map_name}\n"); - if (defined $config{saveMap} && $map->{map_name} =~ /$config{saveMap}/) { - $map_index = $i; - } + next if $map->{status} != 0; + + $fallback_index = $i if !defined $fallback_index; + if (defined $save_map && lc($map->{map_name}) eq $save_map) { + $map_index = $i; + last; + } } + $map_index = $fallback_index if !defined $map_index; + if (!defined $map_index) { + error T("Map server is not ready for any accessible map. Staying on character selection.\n"), 'connection'; + return; + } + $messageSender->sendSelectAccessibleMapname($map_index); } diff --git a/src/Network/XKore2/MapServer.pm b/src/Network/XKore2/MapServer.pm index f93d8a0729..da6f0ec318 100644 --- a/src/Network/XKore2/MapServer.pm +++ b/src/Network/XKore2/MapServer.pm @@ -15,6 +15,7 @@ package Network::XKore2::MapServer; use strict; +use Scalar::Util qw(weaken); use Globals qw( $char $field %statusHandle @skillsID @itemsID %items $portalsList $npcsList $monstersList $playersList $petsList @@ -33,15 +34,32 @@ use Log qw(debug warning error); my $RunOnce = 1; +sub _weak_hook { + my ($self, $callback) = @_; + my $weak_self = $self; + weaken($weak_self); + + return sub { + my $self = $weak_self or return; + $callback->(@_, $self); + }; +} + sub new { my $class = shift; my $self = $class->SUPER::new(@_); debug "XKore 2 Map Server started \n"; - $self->{kore_map_loaded_hook} = Plugins::addHook('packet/map_loaded', \&kore_map_loaded, $self); - $self->{kore_disconnected} = Plugins::addHook('disconnected', \&kore_disconnected, $self); + $self->{kore_map_loaded_hook} = Plugins::addHook('packet/map_loaded', _weak_hook($self, \&kore_map_loaded)); + $self->{kore_disconnected} = Plugins::addHook('disconnected', _weak_hook($self, \&kore_disconnected)); return $self; } +sub DESTROY { + my ($self) = @_; + Plugins::delHook(delete $self->{kore_map_loaded_hook}) if $self->{kore_map_loaded_hook}; + Plugins::delHook(delete $self->{kore_disconnected}) if $self->{kore_disconnected}; +} + # Overrided method. sub onClientNew { my ($self, $client, $index) = @_; diff --git a/src/Task/CalcMapRoute.pm b/src/Task/CalcMapRoute.pm index 50f9b79c06..ff078f9f72 100644 --- a/src/Task/CalcMapRoute.pm +++ b/src/Task/CalcMapRoute.pm @@ -281,6 +281,9 @@ sub canAddOpenListEntry { my ($self, $key, $walk) = @_; return 0 if (exists $self->{closelist}{$key} && $self->{closelist}{$key}{walk} <= $walk); return 0 if (exists $self->{openlist}{$key} && $self->{openlist}{$key}{walk} <= $walk); + # TODO: After fixing the current route-cost mismatch bug, add a stricter + # duplicate-state guard here so the same portal/path key is not re-added to + # the open list/heap over and over with alternate costs during expansion. return 1; } @@ -389,6 +392,8 @@ sub iterate { } $self->{targets} = \@validTargets; + debug "CalcMapRoute - initialized with '".(scalar @validTargets)."' targets (" . join(', ', map { $_->{map} } @validTargets) . ").\n", "calc_map_route" + if $self->shouldLogDebug(); foreach my $target ( @{ $self->{targets} } ) { # Check whether destination is walkable from the starting point. @@ -414,14 +419,14 @@ sub iterate { if ($ret) { for my $dest ($self->getPortalDestinationsForRoute($portal, undef)) { next unless isRoutePointDefined($entry->{dest}{$dest}); + my $portalString = "$portal=$dest"; my $penalty = $self->getMapRouteWeight($self->{source}{map}) + (($entry->{dest}{$dest}{steps} ne '') ? $self->getRouteWeight('NPC') : $self->getRouteWeight('PORTAL')); my $blockedPortalGroups = $self->getBlockedPortalGroupsAfterStep( $entry->{dest}{$dest}, undef, - "Portal branch []", + "Portal branch [] child [$portalString]", ); - my $portalString = "$portal=$dest"; my $key = $self->buildRouteStateKey($portalString, $blockedPortalGroups); my ($extraZeny, $extraTickets) = $self->getPortalStepCost(undef, $entry->{dest}{$dest}); my $value = $self->buildRouteValue( @@ -531,10 +536,26 @@ sub getRoute { # Requires: $self->getStatus() == Task::DONE && !defined($self->getError()) # # Return a string which describes the calculated route. This string has -# the following form: "payon -> pay_arche -> pay_dun00 -> pay_dun01" +# the following form: "payon (228,329) [walk 30] -> pay_arche (36,131) [walk 55] -> pay_dun01 (286,25)" sub getRouteString { my ( $self ) = @_; - join ' -> ', map { $_->{map} } @{ $self->getRoute }, $self->{target}; + + my $formatTarget = sub { + my ($point) = @_; + return $point->{map} unless (defined $point->{x} && defined $point->{y}); + return sprintf("%s (%s,%s)", $point->{map}, $point->{x}, $point->{y}); + }; + + join ' -> ', + (map { + sprintf("%s (%s,%s) [walk %s]", + $_->{map}, + $_->{pos}->{x}, + $_->{pos}->{y}, + $_->{walk} + ) + } @{ $self->getRoute }), + $formatTarget->($self->{target}); } ## @@ -563,153 +584,159 @@ sub searchStep { # selects the node with the lowest walk cost my $parent = $self->shiftOpenlistHeapMinKey(); + if (!defined $parent) { # Fallback: rebuild heap from openlist if it got out of sync. $self->rebuildOpenlistHeap(); $parent = $self->shiftOpenlistHeapMinKey(); } + unless (defined $parent) { $self->{done} = 1; $self->{found} = ''; return 0; } - debug "[CalcMapRoute] [searchStep] $parent (cost $openlist->{$parent}{walk})\n", "calc_map_route" if $self->shouldLogDebug(); - - # Uncomment this if you want minimum MAP count. Otherwise use the above for minimum step count - #foreach my $parent (keys %{$openlist}) - my ($portalString) = $self->parseRouteStateKey($parent); - my ($portal, $dest) = split /=/, $portalString, 2; - # skip if budget exceeded - if ($self->{budget} ne '' && $self->{budget} < $openlist->{$parent}{zeny}) { - # This link is too expensive - delete $openlist->{$parent}; - next; - } else { - # MOVE this entry into the CLOSELIST - $closelist->{$parent} = delete $openlist->{$parent}; + if (!exists $self->{searchStepCount}) { + $self->{searchStepCount} = 0; + } + $self->{searchStepCount}++; + + debug "[CalcMapRoute] [searchStep $self->{searchStepCount}] [size ".(scalar keys %{$self->{openlist}})."] $parent (cost $openlist->{$parent}{walk})\n", "calc_map_route" if $self->shouldLogDebug(); + + my ($portalString) = $self->parseRouteStateKey($parent); + my ($portal, $dest) = split /=/, $portalString, 2; + # skip if budget exceeded + if ($self->{budget} ne '' && $self->{budget} < $openlist->{$parent}{zeny}) { + # This link is too expensive + delete $openlist->{$parent}; + next; + + } else { + # MOVE this entry into the CLOSELIST + $closelist->{$parent} = delete $openlist->{$parent}; + } + + my $map_destination = $self->resolveRouteDestinationEntry($portal, $dest); + # support to multiple targets + foreach my $target ( @{ $self->{targets} } ) { + next unless $map_destination; + my $map_name = $map_destination->{map}; + next if $map_name ne $target->{map}; # checks if the current destination map matches any of the search targets. + my $target_has_coords = hasMapCoords($target); + my $map_destination_has_coords = hasMapCoords($map_destination); + # if no x or y consider that is already at destination + if (!$target_has_coords) { + $self->{found} = $parent; + } + # uses getRoute to check whether you have reached exactly the desired point on the map. + elsif ($map_destination_has_coords + && Task::Route->getRoute($self->{solution}, $target->{field}, $map_destination, $target)) { + my $targetPortalString = "$target->{map} $target->{x} $target->{y}=$target->{map} $target->{x} $target->{y}"; + my ($walk, $value) = $self->buildReachedTargetState( + $parent, + $closelist->{$parent}, + $targetPortalString, + ); + $self->{found} = $walk; + $closelist->{$walk} = $value; } - my $map_destination = $self->resolveRouteDestinationEntry($portal, $dest); - # support to multiple targets - foreach my $target ( @{ $self->{targets} } ) { - next unless $map_destination; - my $map_name = $map_destination->{map}; - next if $map_name ne $target->{map}; # checks if the current destination map matches any of the search targets. - my $target_has_coords = hasMapCoords($target); - my $map_destination_has_coords = hasMapCoords($map_destination); - # if no x or y consider that is already at destination - if (!$target_has_coords) { - $self->{found} = $parent; + # Reconstructs the solution path by traversing the parents backwards, stacking the portals used in the final route. + if ( $self->{found} ) { + $self->{done} = 1; + $self->{mapSolution} = []; + $self->{target} = $target; + $self->{target}->{pos}->{x} = $self->{target}->{x}; + $self->{target}->{pos}->{y} = $self->{target}->{y}; + my $this = $self->{found}; + while ($this) { + unshift @{$self->{mapSolution}}, $self->buildMapSolutionStep($this, $closelist->{$this}); + $this = $closelist->{$this}{parent}; } - # uses getRoute to check whether you have reached exactly the desired point on the map. - elsif ($map_destination_has_coords - && Task::Route->getRoute($self->{solution}, $target->{field}, $map_destination, $target)) { - my $targetPortalString = "$target->{map} $target->{x} $target->{y}=$target->{map} $target->{x} $target->{y}"; - my ($walk, $value) = $self->buildReachedTargetState( - $parent, + return; + } + } + + # get all children of each openlist. + $self->populateOpenListWithGoCommands($dest, $closelist->{$parent}, $parent) unless ($self->{noGoCommand}); + if (!$self->{noTeleSpawn} && canUseTeleportInRouteContext() && $self->isSaveMapSetAndValid()) { + $self->populateOpenListWithWarpToSaveMap($dest, $closelist->{$parent}, $parent); + } + if (!$self->{noWarpItem}) { + $self->populateOpenListWithWarpByItems($dest, $closelist->{$parent}, $parent); + } + + # explore connected portals and NPC warps + my $children = $portals_los{$dest}; + return unless ($children && ref($children) eq 'HASH'); + + foreach my $child (keys %{$children}) { + next unless $children->{$child}; # next if no child + + if (exists $portals_lut{$child} + && !isRouteSourceRemoved($portals_lut{$child}) + && isRoutePointDefined($portals_lut{$child}{source} + )) { + # iterates through the child's/portals that have connection to destination + foreach my $subchild ($self->getPortalDestinationsForRoute($child, $closelist->{$parent})) { + my $destID = $subchild; + next unless isRoutePointDefined($portals_lut{$child}{dest}{$subchild}); + my $mapName = $portals_lut{$child}{source}{map}; + my $portalString = "$child=$subchild"; + ############################################################# + my $penalty = $self->getMapRouteWeight($mapName) + + (($portals_lut{$child}{dest}{$subchild}{steps} ne '') ? $self->getRouteWeight('NPC') : $self->getRouteWeight('PORTAL')); # get node/child penalty based on routeWeights + my $blockedPortalGroups = $self->getBlockedPortalGroupsAfterStep( + $portals_lut{$child}{dest}{$subchild}, $closelist->{$parent}, - $targetPortalString, + "Portal branch [$closelist->{$parent}{portal_string}] child [$portalString]", ); - $self->{found} = $walk; - $closelist->{$walk} = $value; - } - - # Reconstructs the solution path by traversing the parents backwards, stacking the portals used in the final route. - if ( $self->{found} ) { - $self->{done} = 1; - $self->{mapSolution} = []; - $self->{target} = $target; - $self->{target}->{pos}->{x} = $self->{target}->{x}; - $self->{target}->{pos}->{y} = $self->{target}->{y}; - my $this = $self->{found}; - while ($this) { - unshift @{$self->{mapSolution}}, $self->buildMapSolutionStep($this, $closelist->{$this}); - $this = $closelist->{$this}{parent}; - } - return; + my $key = $self->buildRouteStateKey($portalString, $blockedPortalGroups); + my ($extraZeny, $extraTickets) = $self->getPortalStepCost($closelist->{$parent}, $portals_lut{$child}{dest}{$subchild}); + my $value = $self->buildRouteValue( + type => 'portal_or_npc', + parent => $parent, + baseCost => $closelist->{$parent}, + extraWalk => $penalty + $children->{$child}, + extraZeny => $extraZeny, + extraTickets => $extraTickets, + allow_ticket => $portals_lut{$child}{dest}{$subchild}{allow_ticket}, + blockedPortalGroups => $blockedPortalGroups, + ); + next unless $self->canAddOpenListEntry($key, $value->{walk}); + $self->add_key_to_openList($key, $value); } + next; } - # get all children of each openlist. - $self->populateOpenListWithGoCommands($dest, $closelist->{$parent}, $parent) unless ($self->{noGoCommand}); - if (!$self->{noTeleSpawn} && canUseTeleportInRouteContext() && $self->isSaveMapSetAndValid()) { - $self->populateOpenListWithWarpToSaveMap($dest, $closelist->{$parent}, $parent); - } - if (!$self->{noWarpItem}) { - $self->populateOpenListWithWarpByItems($dest, $closelist->{$parent}, $parent); - } - - # explore connected portals and NPC warps - my $children = $portals_los{$dest}; - if ($children && ref($children) eq 'HASH') { - foreach my $child (keys %{$children}) { - next unless $children->{$child}; # next if no child - - if (exists $portals_lut{$child} - && !isRouteSourceRemoved($portals_lut{$child}) - && isRoutePointDefined($portals_lut{$child}{source})) { - # iterates through the child's/portals that have connection to destination - foreach my $subchild ($self->getPortalDestinationsForRoute($child, $closelist->{$parent})) { - my $destID = $subchild; - next unless isRoutePointDefined($portals_lut{$child}{dest}{$subchild}); - my $mapName = $portals_lut{$child}{source}{map}; - ############################################################# - my $penalty = $self->getMapRouteWeight($mapName) + - (($portals_lut{$child}{dest}{$subchild}{steps} ne '') ? $self->getRouteWeight('NPC') : $self->getRouteWeight('PORTAL')); # get node/child penalty based on routeWeights - my $thisWalk = $penalty + $closelist->{$parent}{walk} + $children->{$child}; # calculate the final node/child penalty routeWeights + walk distance + accumulated cost - my $blockedPortalGroups = $self->getBlockedPortalGroupsAfterStep( - $portals_lut{$child}{dest}{$subchild}, - $closelist->{$parent}, - "Portal branch [$closelist->{$parent}{portal_string}]", - ); - my $portalString = "$child=$subchild"; - my $key = $self->buildRouteStateKey($portalString, $blockedPortalGroups); - my ($extraZeny, $extraTickets) = $self->getPortalStepCost($closelist->{$parent}, $portals_lut{$child}{dest}{$subchild}); - next unless $self->canAddOpenListEntry($key, $thisWalk); - my $value = $self->buildRouteValue( - type => 'portal_or_npc', - parent => $parent, - baseCost => $closelist->{$parent}, - extraWalk => $penalty + $children->{$child}, - extraZeny => $extraZeny, - extraTickets => $extraTickets, - allow_ticket => $portals_lut{$child}{dest}{$subchild}{allow_ticket}, - blockedPortalGroups => $blockedPortalGroups, - ); - $self->add_key_to_openList($key, $value); - } - next; - } - - next if $self->{noAirship}; - next unless exists $portals_airships{$child}; - next if isRouteSourceRemoved($portals_airships{$child}); - next unless isRoutePointDefined($portals_airships{$child}{source}); - next unless $portals_airships{$child}{dest} && ref($portals_airships{$child}{dest}) eq 'HASH'; - # iterates airships - foreach my $subchild (grep { $portals_airships{$child}{dest}{$_}{enabled} } keys %{$portals_airships{$child}{dest}}) { - my $destID = $subchild; - next unless isRoutePointDefined($portals_airships{$child}{dest}{$subchild}); - my $mapName = $portals_airships{$child}{source}{map}; - ############################################################# - my $penalty = $self->getMapRouteWeight($mapName) + $self->getRouteWeight('AIRSHIP'); # get node/child penalty based on routeWeights - my $thisWalk = $penalty + $closelist->{$parent}{walk} + $children->{$child}; # calculate the final node/child penalty routeWeights + walk distance + accumulated cost - my $key = $self->buildRouteStateKey("$child=$subchild", $closelist->{$parent}{blockedPortalGroups}); - next unless $self->canAddOpenListEntry($key, $thisWalk); - my $value = $self->buildRouteValue( - type => 'airship', - parent => $parent, - baseCost => $closelist->{$parent}, - extraWalk => $penalty + $children->{$child}, - blockedPortalGroups => $self->cloneBlockedPortalGroups($closelist->{$parent}), - ); - $value->{airship_message} = $portals_airships{$child}{dest}{$subchild}{message}; - $value->{is_airship} = 1; - $self->add_key_to_openList($key, $value); - } - } + next if $self->{noAirship}; + next unless exists $portals_airships{$child}; + next if isRouteSourceRemoved($portals_airships{$child}); + next unless isRoutePointDefined($portals_airships{$child}{source}); + next unless $portals_airships{$child}{dest} && ref($portals_airships{$child}{dest}) eq 'HASH'; + # iterates airships + foreach my $subchild (grep { $portals_airships{$child}{dest}{$_}{enabled} } keys %{$portals_airships{$child}{dest}}) { + my $destID = $subchild; + next unless isRoutePointDefined($portals_airships{$child}{dest}{$subchild}); + my $mapName = $portals_airships{$child}{source}{map}; + ############################################################# + my $penalty = $self->getMapRouteWeight($mapName) + $self->getRouteWeight('AIRSHIP'); # get node/child penalty based on routeWeights + my $thisWalk = $penalty + $closelist->{$parent}{walk} + $children->{$child}; # calculate the final node/child penalty routeWeights + walk distance + accumulated cost + my $key = $self->buildRouteStateKey("$child=$subchild", $closelist->{$parent}{blockedPortalGroups}); + next unless $self->canAddOpenListEntry($key, $thisWalk); + my $value = $self->buildRouteValue( + type => 'airship', + parent => $parent, + baseCost => $closelist->{$parent}, + extraWalk => $penalty + $children->{$child}, + blockedPortalGroups => $self->cloneBlockedPortalGroups($closelist->{$parent}), + ); + $value->{airship_message} = $portals_airships{$child}{dest}{$subchild}{message}; + $value->{is_airship} = 1; + $self->add_key_to_openList($key, $value); } + } } sub getPortalDestinationsForRoute { @@ -731,14 +758,6 @@ sub isPortalDestinationEnabledForRoute { my $groupName = $entry->{dynamicPortalGroup}; if (defined $groupName && $groupName ne '' && $self->isPortalGroupBlockedForValue($groupName, $currentValue)) { - if ($self->shouldLogDebug()) { - my $branchPortal = ($currentValue && ref($currentValue) eq 'HASH') ? ($currentValue->{portal_string} || '') : ''; - my $blocked = $self->formatBlockedPortalGroups($self->cloneBlockedPortalGroups($currentValue)); - debug sprintf( - "CalcMapRoute - Blocking portal %s=%s because group '%s' is blocked for branch [%s] (blocked groups: %s).\n", - $portal, $destID, $groupName, $branchPortal, $blocked - ), "calc_map_route"; - } return 0; } diff --git a/src/Task/MapRoute.pm b/src/Task/MapRoute.pm index db493fb37f..b516b825a6 100644 --- a/src/Task/MapRoute.pm +++ b/src/Task/MapRoute.pm @@ -31,7 +31,6 @@ use Network; use Plugins; use Misc qw(canUseTeleport portalExists suspendRouteSource); use Utils qw(timeOut blockDistance existsInList calcPosFromPathfinding actorFinishedMovement); -use Utils::PathFinding; use Utils::DataStructures qw(hashSafeGetValue); use Utils::Exceptions; use AI qw(ai_useTeleport); @@ -185,7 +184,7 @@ sub iterate { debug "Map Router has finished traversing the map solution\n", "map_route"; } elsif ( $field->baseName ne $self->{mapSolution}[0]{map} - || ( $self->{mapChanged} && !$self->{teleport} ) ) { + || ( $self->{mapChanged} && (!$self->{teleport} || $self->_hasReachedSameMapPortalDestination($self->{mapSolution}[0])) ) ) { # Solution Map does not match current map debug "Current map " . $field->baseName . " does not match solution [ $self->{mapSolution}[0]{portal} ].\n", "map_route"; delete $self->{substage}; @@ -420,7 +419,8 @@ sub iterate { maxTime => $self->{maxTime}, avoidWalls => $self->{avoidWalls}, randomFactor => $self->{randomFactor}, - useManhattan => $self->{useManhattan} + useManhattan => $self->{useManhattan}, + isPortalRoute => 1 ); $task->{$_} = $self->{$_} for qw(targetNpcPos attackID sendAttackWithMove attackOnRoute noSitAuto LOSSubRoute meetingSubRoute isRandomWalk isFollow isIdleWalk isSlaveRescue isMoveNearSlave isEscape isItemTake isItemGather isDeath isToLockMap runFromTarget); $self->setSubtask($task); @@ -664,7 +664,8 @@ sub iterate { warning TF("Guessing our desired portal to be %s (%s,%s).\n", $field->baseName, $self->{guess_portal}{pos}{x}, $self->{guess_portal}{pos}{y}), "map_route"; my %params = ( field => $field, - solution => \@solution + solution => \@solution, + isPortalRoute => 1, ); $params{$_} = $self->{guess_portal}{pos}{$_} for qw(x y); $params{$_} = $self->{$_} for qw(actor maxTime avoidWalls randomFactor useManhattan); @@ -672,7 +673,7 @@ sub iterate { $task->{$_} = $self->{$_} for qw(targetNpcPos attackID sendAttackWithMove attackOnRoute noSitAuto LOSSubRoute meetingSubRoute isRandomWalk isFollow isIdleWalk isSlaveRescue isMoveNearSlave isEscape isItemTake isItemGather isDeath isToLockMap runFromTarget); $self->setSubtask($task); - } elsif ( $config{route_removeMissingPortals} && blockDistance($self->{actor}{pos_to}, $self->{mapSolution}[0]{pos}) == 0 && actorFinishedMovement($self->{actor}, $field, $timeout{ai_portal_wait}{timeout}, 1) ) { + } elsif ( $config{route_removeMissingPortals} && blockDistance($self->{actor}{pos_to}, $self->{mapSolution}[0]{pos}) <= 1 && actorFinishedMovement($self->{actor}, $field, $timeout{ai_portal_wait}{timeout}, 1) ) { if (!exists $timeout{ai_portal_give_up}{time}) { $timeout{ai_portal_give_up}{time} = time; $timeout{ai_portal_give_up}{timeout} = $timeout{ai_portal_give_up}{timeout} || 10; @@ -704,67 +705,66 @@ sub iterate { } else { my $walk = 1; - # Teleport until we're close enough to the portal - if (!defined $self->{teleport} || $self->{mapChanged}) { - $self->{teleport} = $self->isRouteTeleportAllowedOnMap($field->baseName) ? $config{route_teleport} : 0; - } + if ($walk) { + if ( Task::Route->getRoute( \@solution, $field, $self->{actor}{pos}, $self->{mapSolution}[0]{pos} ) ) { + # Portal is reachable from current position - if ($self->{teleport} && !$field->isCity - && !existsInList($config{route_teleport_notInMaps}, $field->baseName) - && ( !$config{route_teleport_maxTries} || $self->{teleportTries} <= $config{route_teleport_maxTries} )) { - my $minDist = $config{route_teleport_minDistance}; + # Teleport until we're close enough to the portal + if (!defined $self->{teleport} || $self->{mapChanged}) { + $self->{teleport} = $self->isRouteTeleportAllowedOnMap($field->baseName) ? $config{route_teleport} : 0; + } - if ($self->{mapChanged}) { - undef $self->{sentTeleport}; - undef $self->{mapChanged}; - } + if ($self->{teleport} && !$field->isCity + && !existsInList($config{route_teleport_notInMaps}, $field->baseName) + && ( !$config{route_teleport_maxTries} || $self->{teleportTries} <= $config{route_teleport_maxTries} )) { + my $minDist = $config{route_teleport_minDistance}; - if (!$self->{sentTeleport}) { - # Find first inter-map portal - my $portal; - for my $x (@{$self->{mapSolution}}) { - $portal = $x; - last unless _isSameMapPortalStep($x); - } + if ($self->{mapChanged}) { + undef $self->{sentTeleport}; + undef $self->{mapChanged}; + } - my $dist = new PathFinding( - start => $self->{actor}{pos_to}, - dest => $portal->{pos}, - field => $field - )->runcount; - debug "Distance to portal ($portal->{portal}) is $dist\n", "map_route"; + if (!$self->{sentTeleport}) { + # Find first inter-map portal + my $portal; + for my $x (@{$self->{mapSolution}}) { + $portal = $x; + last unless _isSameMapPortalStep($x); + } + + my $dist = scalar @solution; - if ($dist < 0 || $dist > $minDist) { - if ($dist > 0 && $config{route_teleport_maxTries} && $self->{teleportTries} >= $config{route_teleport_maxTries}) { - debug "Teleported $config{route_teleport_maxTries} times. Falling back to walking.\n", "map_route"; - } else { - message TF("Attempting to teleport near portal, try #%s\n", ($self->{teleportTries} + 1)), "map_route"; - if (!canUseTeleport(1)) { - $self->{teleport} = 0; - } else { - ai_useTeleport(1); - $walk = 0; - $self->{sentTeleport} = 1; - $self->{teleportTime} = time; - $self->{teleportTries}++; + if ($dist > $minDist) { + debug "[MapRoute] [Teleport] Distance to portal ($portal->{portal}) is $dist\n", "map_route"; + if ($dist > 0 && $config{route_teleport_maxTries} && $self->{teleportTries} >= $config{route_teleport_maxTries}) { + message "[MapRoute] [Teleport] Teleported $config{route_teleport_maxTries} times. Falling back to walking.\n", "map_route"; + } else { + message TF("[MapRoute] [Teleport] Attempting to teleport near portal (dist %s > max %s), try #%s\n", $dist, $minDist, ($self->{teleportTries} + 1)), "map_route"; + if (!canUseTeleport(1)) { + $self->{teleport} = 0; + } else { + ai_useTeleport(1); + $walk = 0; + $self->{sentTeleport} = 1; + $self->{teleportTime} = time; + $self->{teleportTries}++; + } + } } + + } elsif (timeOut($self->{teleportTime}, 4)) { + debug TF("[MapRoute] [Teleport] Unable to teleport on map %s; falling back to walking on this map.\n", $field->baseName), "map_route"; + $self->{noRouteTeleportMaps}{$field->baseName} = time; + $self->{teleport} = 0; + delete $self->{sentTeleport}; + delete $self->{teleportTime}; + } else { + $walk = 0; } } - } elsif (timeOut($self->{teleportTime}, 4)) { - debug TF("Unable to teleport on map %s; falling back to walking on this map.\n", $field->baseName), "map_route"; - $self->{noRouteTeleportMaps}{$field->baseName} = time; - $self->{teleport} = 0; - delete $self->{sentTeleport}; - delete $self->{teleportTime}; - } else { - $walk = 0; - } - } + return if ($walk == 0); - if ($walk) { - if ( Task::Route->getRoute( \@solution, $field, $self->{actor}{pos}, $self->{mapSolution}[0]{pos} ) ) { - # Portal is reachable from current position # >> Then "route" to it debug "Portal route within same map.\n", "map_route"; my %plugin_args; @@ -781,7 +781,8 @@ sub iterate { maxTime => $self->{maxTime}, avoidWalls => $self->{avoidWalls}, randomFactor => $self->{randomFactor}, - useManhattan => $self->{useManhattan} + useManhattan => $self->{useManhattan}, + isPortalRoute => 1 ); $task->{stopWhenMapChanged} = 1 if (_isSameMapPortalStep($self->{mapSolution}[0])); $task->{$_} = $self->{$_} for qw(targetNpcPos attackID sendAttackWithMove attackOnRoute noSitAuto LOSSubRoute meetingSubRoute isRandomWalk isFollow isIdleWalk isSlaveRescue isMoveNearSlave isEscape isItemTake isItemGather isDeath isToLockMap runFromTarget); @@ -820,6 +821,38 @@ sub _isSameMapPortalStep { return $from_map eq $to_map; } +sub _sameMapPortalDestination { + my ($step) = @_; + return unless _isSameMapPortalStep($step); + return unless defined $step->{portal}; + + my (undef, $to) = split(/=/, $step->{portal}, 2); + return unless defined $to; + + my ($dest_map, $dest_x, $dest_y) = split(/\s+/, $to, 3); + return unless (defined $dest_map && defined $dest_x && defined $dest_y); + + return { + map => $dest_map, + x => $dest_x, + y => $dest_y, + }; +} + +sub _hasReachedSameMapPortalDestination { + my ($self, $step) = @_; + my $dest = _sameMapPortalDestination($step); + return 0 unless $dest; + return 0 unless ($field && $field->baseName eq $dest->{map}); + + for my $pos ($self->{actor}{pos}, $self->{actor}{pos_to}) { + next unless $pos; + return 1 if blockDistance($pos, $dest) <= 3; + } + + return 0; +} + sub _currentRouteSourceID { my ($self) = @_; return join(' ', @@ -838,6 +871,33 @@ sub _currentPortalSourceEntry { return $routeSources->{$nodeID}{source}; } +sub _currentPortalUpdateCandidate { + my ($self) = @_; + return unless ($self->{mapSolution} + && @{$self->{mapSolution}} + && $self->{mapSolution}[0]{portal} + && !$self->{mapSolution}[0]{steps}); + + my $nodeID = $self->_currentRouteSourceID(); + return unless exists $portals_lut{$nodeID}; + + my (undef, $to) = split(/=/, $self->{mapSolution}[0]{portal}, 2); + return unless defined $to; + + my ($dest_map, $dest_x, $dest_y) = split(/\s+/, $to, 3); + return unless (defined $dest_map && defined $dest_x && defined $dest_y); + + return { + oldSourceMap => $self->{mapSolution}[0]{map}, + oldSourceX => $self->{mapSolution}[0]{pos}{x}, + oldSourceY => $self->{mapSolution}[0]{pos}{y}, + oldDestMap => $dest_map, + oldDestX => $dest_x, + oldDestY => $dest_y, + time => time, + }; +} + sub _suspendCurrentRouteSourceForRecalc { my ($self, $dataset, $logMessage) = @_; my $removed = suspendRouteSource($self->_currentRouteSourceID(), dataset => $dataset); @@ -1009,6 +1069,12 @@ sub subtaskDone { sub mapChanged { my (undef, undef, $holder) = @_; my $self = $holder->[0]; + + if ($config{portalUpdatePosition}) { + my $candidate = $self->_currentPortalUpdateCandidate(); + $ai_v{portalUpdatePosition_candidate} = $candidate if $candidate; + } + $self->{mapChanged} = 1; my $subtask = $self->getSubtask(); diff --git a/src/Task/Route.pm b/src/Task/Route.pm index aabc3b5234..3ca49c1f3c 100644 --- a/src/Task/Route.pm +++ b/src/Task/Route.pm @@ -116,7 +116,7 @@ sub new { ArgumentException->throw(error => "Invalid Coordinates argument."); } - my $allowed = new Set(qw(targetNpcPos maxDistance maxTime distFromGoal pyDistFromGoal avoidWalls randomFactor useManhattan notifyUponArrival attackID sendAttackWithMove attackOnRoute noSitAuto LOSSubRoute meetingSubRoute isRandomWalk isFollow isIdleWalk isSlaveRescue isMoveNearSlave isEscape isItemTake isItemGather isDeath isToLockMap runFromTarget)); + my $allowed = new Set(qw(targetNpcPos maxDistance maxTime distFromGoal pyDistFromGoal avoidWalls randomFactor useManhattan notifyUponArrival attackID sendAttackWithMove attackOnRoute noSitAuto LOSSubRoute meetingSubRoute isRandomWalk isFollow isIdleWalk isSlaveRescue isMoveNearSlave isEscape isItemTake isItemGather isDeath isToLockMap runFromTarget isPortalRoute)); foreach my $key (keys %args) { if ($allowed->has($key) && defined($args{$key})) { $self->{$key} = $args{$key}; @@ -254,53 +254,6 @@ sub iterate { $extra_time = 0 unless (defined $extra_time); my $calc_pos = calcPosFromPathfinding($field, $self->{actor}, $extra_time); - - my $walk = 1; - if ($config{route_teleport} == 2 - && !$self->{isRandomWalk} - && !$self->{disableOnMapTeleport} - && !$field->isCity - && !existsInList($config{route_teleport_notInMaps}, $field->baseName) - && (!$config{route_teleport_maxTries} || $self->{teleportTries} <= $config{route_teleport_maxTries})) { - my $minDist = $config{route_teleport_minDistance}; - - if ($self->{mapChanged}) { - undef $self->{sentTeleport}; - undef $self->{mapChanged}; - } - - if (!$self->{sentTeleport}) { - my $dist = new PathFinding( - start => $self->{actor}{pos_to}, - dest => $self->{dest}{pos}, - field => $field - )->runcount; - debug "Distance to destination ($self->{dest}{pos}{x},$self->{dest}{pos}{y}) is $dist\n", "route"; - - if ($dist < 0 || $dist > $minDist) { - if ($dist > 0 && $config{route_teleport_maxTries} && $self->{teleportTries} >= $config{route_teleport_maxTries}) { - debug "Teleported $config{route_teleport_maxTries} times on same-map route. Falling back to walking.\n", "route"; - } else { - message TF("Attempting to teleport near destination, try #%s\n", ($self->{teleportTries} + 1)), "route"; - if (!canUseTeleport(1)) { - $self->{disableOnMapTeleport} = 1; - } else { - ai_useTeleport(1); - $walk = 0; - $self->{sentTeleport} = 1; - $self->{teleportTime} = time; - $self->{teleportTries}++; - } - } - } - } elsif (timeOut($self->{teleportTime}, 4)) { - debug "Unable to teleport; falling back to walking.\n", "route"; - $self->{disableOnMapTeleport} = 1; - } else { - $walk = 0; - } - } - return unless $walk; debug "Route $self->{actor}: Calculating. Your pos ($pos->{x} $pos->{y}). Your pos_to ($pos_to->{x} $pos_to->{y}). calcPosFromPathfinding ($calc_pos->{x} $calc_pos->{y})\n", "route"; @@ -481,7 +434,7 @@ sub iterate { $self->{lastStep} = 0; - if ($stepsleft == 2 && isCellOccupied($solution->[-1], $self->{actor}) && !$self->{meetingSubRoute}) { + if (isCellOccupied($solution->[-1], $self->{actor}) && blockDistance($current_pos_to, $self->{dest}{pos}) <= 1) { # 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}) { @@ -572,6 +525,51 @@ sub iterate { } } else { + + my $walk = 1; + if ($config{route_teleport} == 2 + && !$self->{isRandomWalk} + && !$self->{disableOnMapTeleport} + && !$field->isCity + && !existsInList($config{route_teleport_notInMaps}, $field->baseName) + && (!$config{route_teleport_maxTries} || $self->{teleportTries} <= $config{route_teleport_maxTries})) { + my $minDist = $config{route_teleport_minDistance}; + + if ($self->{mapChanged}) { + undef $self->{sentTeleport}; + undef $self->{mapChanged}; + } + + if (!$self->{sentTeleport}) { + my $dist = $stepsleft; + debug "[Route] [Teleport] Distance to destination ($self->{dest}{pos}{x},$self->{dest}{pos}{y}) is $dist\n", "route"; + + if ($dist > $minDist) { + if ($dist > 0 && $config{route_teleport_maxTries} && $self->{teleportTries} >= $config{route_teleport_maxTries}) { + debug "[Route] [Teleport] Teleported $config{route_teleport_maxTries} times on same-map route. Falling back to walking.\n", "route"; + } else { + message TF("[Route] [Teleport] Attempting to teleport near destination (dist %s > max %s), try #%s\n", $dist, $minDist, ($self->{teleportTries} + 1)), "route"; + if (!canUseTeleport(1)) { + $self->{disableOnMapTeleport} = 1; + } else { + ai_useTeleport(1); + $walk = 0; + $self->{sentTeleport} = 1; + $self->{teleportTime} = time; + $self->{teleportTries}++; + } + } + } + } elsif (timeOut($self->{teleportTime}, 4)) { + debug "[Route] [Teleport] Unable to teleport; falling back to walking.\n", "route"; + $self->{disableOnMapTeleport} = 1; + } else { + $walk = 0; + } + } + return unless $walk; + + # We're either starting to move or already moving, so send out more # move commands periodically to keep moving and updating our position my $begin = time; @@ -611,7 +609,7 @@ sub iterate { $move_step_index = 0 if $move_step_index < 0; - debug "[Route] [$self->{loop}] step_index $self->{step_index} | move_step_index $move_step_index | lastStep $self->{lastStep} | self->{start} $self->{start}\n", 'route', 1; + debug "[Route] [$self->{loop}] step_index $self->{step_index} | move_step_index $move_step_index | lastStep $self->{lastStep} | self->{start} $self->{start}\n", 'route', 2; my $requested_move_step_index = $move_step_index; @@ -857,7 +855,7 @@ sub _resetRouteForMoveSelection { 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"; + debug "Route $self->{actor} - trimming down move_step_index from $$move_step_index_ref to (".($$move_step_index_ref - 1).") $reason\n", "route", 2; $$move_step_index_ref--; if (($requested_move_step_index - $$move_step_index_ref) >= ROUTE_CLIENT_PATH_RESET_TRIM_STEPS) { @@ -893,12 +891,19 @@ sub getRoute { my ($class, $solution, $field, $start, $dest, $avoidWalls, $randomFactor, $useManhattan, $liveRoute, $addLimits) = @_; assertClass($field, 'Field') if DEBUG; - my $dest_has_x = defined $dest->{x} && $dest->{x} ne ''; - my $dest_has_y = defined $dest->{y} && $dest->{y} ne ''; - my $start_has_x = defined $start->{x} && $start->{x} ne ''; - my $start_has_y = defined $start->{y} && $start->{y} ne ''; + my $dest_has_x = exists $dest->{x} && defined $dest->{x} && $dest->{x} ne ''; + my $dest_has_y = exists $dest->{y} && defined $dest->{y} && $dest->{y} ne ''; + my $start_has_x = exists $start->{x} && defined $start->{x} && $start->{x} ne ''; + my $start_has_y = exists $start->{y} && defined $start->{y} && $start->{y} ne ''; if (!$dest_has_x || !$dest_has_y || !$start_has_x || !$start_has_y) { - Log::error "[getRoute] Called with invalid coordinates\n"; + my $missing = join(', ', + (!$start_has_x ? 'start.x' : ()), + (!$start_has_y ? 'start.y' : ()), + (!$dest_has_x ? 'dest.x' : ()), + (!$dest_has_y ? 'dest.y' : ()) + ); + my $fieldName = $field->baseName; + Log::error "[getRoute] Called with invalid coordinates on field $fieldName. Missing '$missing'.\n"; Misc::print_callers(); @{$solution} = () if ($solution); return 0; @@ -914,7 +919,12 @@ sub getRoute { $closest_dest = $field->closestWalkableSpot(\%dest, 10) if(!$closest_dest); # can't find a closest walkable spot if (!defined $closest_start || !defined $closest_dest) { - Log::error "[getRoute] Called with no valid closest coordinates ($start->{x}, $start->{y}) to ($dest->{x}, $dest->{y})\n"; + my $fieldName = $field->baseName; + my $invalid_coords = join(', ', + (!defined $closest_start ? "closest walkable spot to start ($start->{x}, $start->{y})" : ()), + (!defined $closest_dest ? "closest walkable spot to dest ($dest->{x}, $dest->{y})" : ()) + ); + Log::error "[getRoute] Called with no valid closest coordinates on field $fieldName ($start->{x}, $start->{y}) to ($dest->{x}, $dest->{y}). Invalid coordinates: $invalid_coords\n"; Misc::print_callers(); return 0; } diff --git a/src/Task/Teleport.pm b/src/Task/Teleport.pm index 2b779cf98e..c220c72a8e 100644 --- a/src/Task/Teleport.pm +++ b/src/Task/Teleport.pm @@ -9,6 +9,7 @@ use Carp::Assert; use Time::HiRes qw(time); use Modules 'register'; +use Skill; use Task::SitStand; use base 'Task::WithSubtask'; use Globals qw($messageSender $net %timeout); @@ -179,6 +180,24 @@ sub equipTeleportItem { $self->{retry}{time} = time; } +sub hasEnoughSPForTeleportSkill { + my ($self, $skill_level) = @_; + return 0 unless $skill_level; + + my $skill = Skill->new(handle => 'AL_TELEPORT'); + my $sp_cost = $skill->getSP($skill_level); + return 1 unless defined $sp_cost; + + return (($self->{actor}{sp} // 0) >= $sp_cost) ? 1 : 0; +} + +sub isTeleportSkillSuppressedByStatus { + my ($self) = @_; + return 1 if $self->{actor}->{muted}; + return 0 unless $self->{actor}->can('statusActive'); + return $self->{actor}->statusActive('HEALTHSTATE_SILENCE, EFST_HEALTHSTATE_SILENCE') ? 1 : 0; +} + sub isTeleportItemEquipRequirementSatisfied { my ($self, $item) = @_; return 0 unless $item; diff --git a/src/Task/Teleport/Random.pm b/src/Task/Teleport/Random.pm index 23c09f4421..354fdd04f9 100644 --- a/src/Task/Teleport/Random.pm +++ b/src/Task/Teleport/Random.pm @@ -44,10 +44,11 @@ sub getInventoryItem { # return 1 if actor has teleport skill lvl sub canUseSkill { my ($self) = @_; - return 0 if ($self->{actor}->{muted}); + return 0 if $self->isTeleportSkillSuppressedByStatus; return 0 if Misc::isTeleportSkillBlockedOnMap($field->baseName); return 0 if defined $config{'teleportAuto_useSkill'} && $config{'teleportAuto_useSkill'} == 0; - return $self->{actor}->getSkillLevel(new Skill(handle => 'AL_TELEPORT')) ? 1 : 0; + return 0 unless $self->{actor}->getSkillLevel(new Skill(handle => 'AL_TELEPORT')); + return $self->hasEnoughSPForTeleportSkill(1); } # return the number of items necessary to teleport diff --git a/src/Task/Teleport/Respawn.pm b/src/Task/Teleport/Respawn.pm index fc930fdc84..8386e5e729 100644 --- a/src/Task/Teleport/Respawn.pm +++ b/src/Task/Teleport/Respawn.pm @@ -44,11 +44,12 @@ sub getInventoryItem { # return 1 if char have skill teleport at lv 2 sub canUseSkill { my ($self) = @_; - return 0 if ($self->{actor}->{muted}); + return 0 if $self->isTeleportSkillSuppressedByStatus; return 0 if Misc::isTeleportSkillBlockedOnMap($field->baseName); return 0 if defined $config{'teleportAuto_useItemForRespawn'} && $config{'teleportAuto_useItemForRespawn'} == 1; return 0 if defined $config{'teleportAuto_useSkill'} && $config{'teleportAuto_useSkill'} == 0; - return ($self->{actor}->getSkillLevel(new Skill(handle => 'AL_TELEPORT')) == 2) ? 1 : 0; + return 0 unless ($self->{actor}->getSkillLevel(new Skill(handle => 'AL_TELEPORT')) == 2); + return $self->hasEnoughSPForTeleportSkill(2); } # return the number of items necessary to teleport diff --git a/src/Utils.pm b/src/Utils.pm index a19306cce4..1ccdda139a 100644 --- a/src/Utils.pm +++ b/src/Utils.pm @@ -1260,10 +1260,14 @@ sub dataWaiting { return 0 if (!defined $r_fh || !defined $$r_fh); $timeout = 0.01 unless (defined $timeout); + my $fd = eval { fileno($$r_fh) }; + return 0 if (!defined $fd || $fd < 0); + my $bits = ''; - vec($bits, fileno($$r_fh), 1) = 1; + vec($bits, $fd, 1) = 1; - my $nfound = select($bits, undef, undef, $timeout); + my $nfound = eval { select($bits, undef, undef, $timeout) }; + return 0 if (!defined $nfound); return ($nfound > 0); } diff --git a/src/auto/XSTools/OSL/IO/BufferedOutputStream.cpp b/src/auto/XSTools/OSL/IO/BufferedOutputStream.cpp index 6b9bdcda41..ae7b562898 100644 --- a/src/auto/XSTools/OSL/IO/BufferedOutputStream.cpp +++ b/src/auto/XSTools/OSL/IO/BufferedOutputStream.cpp @@ -35,18 +35,41 @@ namespace OSL { } BufferedOutputStream::~BufferedOutputStream() { - close(); + try { + close(); + } catch (...) { + } } void BufferedOutputStream::close() { if (stream != NULL) { - flush(); - stream->close(); - stream->unref(); - delete buffer; + OutputStream *oldStream = stream; + char *oldBuffer = buffer; + stream = NULL; buffer = NULL; + + try { + if (count > 0) { + unsigned int c = count; + count = 0; + oldStream->write(oldBuffer, c); + oldStream->flush(); + } + oldStream->close(); + } catch (const IOException &e) { + oldStream->unref(); + delete[] oldBuffer; + throw IOException(e.getMessage(), e.getCode()); + } catch (...) { + oldStream->unref(); + delete[] oldBuffer; + throw; + } + + oldStream->unref(); + delete[] oldBuffer; } } diff --git a/src/auto/XSTools/OSL/Net/Unix/ServerSocket.cpp b/src/auto/XSTools/OSL/Net/Unix/ServerSocket.cpp index 9be6db27f4..ce3f44ea0a 100644 --- a/src/auto/XSTools/OSL/Net/Unix/ServerSocket.cpp +++ b/src/auto/XSTools/OSL/Net/Unix/ServerSocket.cpp @@ -52,15 +52,13 @@ class UnixServerSocket: public ServerSocket { setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)); struct sockaddr_in addr; - char *c_address = NULL; addr.sin_family = AF_INET; addr.sin_port = htons (port); if (address == NULL) { addr.sin_addr.s_addr = htonl(INADDR_ANY); } else { - c_address = strdup(address); - addr.sin_addr.s_addr = inet_addr(c_address); + addr.sin_addr.s_addr = inet_addr(address); } if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) == -1) { char message[200]; @@ -72,10 +70,6 @@ class UnixServerSocket: public ServerSocket { throw SocketException(message, errno); } - if (c_address != NULL) { - free(c_address); - } - if (port == 0) { socklen_t len = sizeof(addr); if (getsockname(fd, (struct sockaddr *) &addr, &len) == -1) { diff --git a/src/auto/XSTools/OSL/Net/Unix/Socket.cpp b/src/auto/XSTools/OSL/Net/Unix/Socket.cpp index 5de809305c..d9aa05fd66 100644 --- a/src/auto/XSTools/OSL/Net/Unix/Socket.cpp +++ b/src/auto/XSTools/OSL/Net/Unix/Socket.cpp @@ -129,6 +129,8 @@ namespace _Intern { UnixSocket::UnixSocket(const char *address, unsigned short port) { + in = NULL; + out = NULL; int fd = socket (PF_INET, SOCK_STREAM, 0); if (fd == -1) { char message[200]; @@ -166,28 +168,49 @@ namespace _Intern { } UnixSocket::UnixSocket(int fd) { + in = NULL; + out = NULL; construct(fd); } void UnixSocket::construct(int fd) { + this->fd = fd; #ifdef SO_NOSIGPIPE int enabled = 1; setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); #endif - - in = new InStream(fd); - out = new OutStream(fd); - - this->fd = fd; + + try { + in = new InStream(fd); + out = new OutStream(fd); + } catch (...) { + if (in != NULL) { + in->unref(); + in = NULL; + } + if (out != NULL) { + out->unref(); + out = NULL; + } + close(fd); + this->fd = -1; + throw; + } } UnixSocket::~UnixSocket() { - in->close(); - in->unref(); - out->close(); - out->unref(); - close(fd); + if (in != NULL) { + in->close(); + in->unref(); + } + if (out != NULL) { + out->close(); + out->unref(); + } + if (fd != -1) { + close(fd); + } } InputStream * diff --git a/src/auto/XSTools/OSL/Net/Win32/ServerSocket.cpp b/src/auto/XSTools/OSL/Net/Win32/ServerSocket.cpp index 4e8e47962d..b9151f244e 100644 --- a/src/auto/XSTools/OSL/Net/Win32/ServerSocket.cpp +++ b/src/auto/XSTools/OSL/Net/Win32/ServerSocket.cpp @@ -55,14 +55,12 @@ class WinServerSocket: public ServerSocket { } struct sockaddr_in addr; - char *c_address = NULL; addr.sin_family = AF_INET; if (address == NULL) { addr.sin_addr.s_addr = htonl(INADDR_ANY); } else { - c_address = strdup(address); - addr.sin_addr.s_addr = inet_addr(c_address); + addr.sin_addr.s_addr = inet_addr(address); } addr.sin_port = htons(port); if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) == SOCKET_ERROR) { @@ -75,10 +73,6 @@ class WinServerSocket: public ServerSocket { throw SocketException(message, error); } - if (c_address != NULL) { - free(c_address); - } - if (port == 0) { int len = sizeof(addr); if (getsockname(fd, (struct sockaddr *) &addr, &len) == SOCKET_ERROR) { diff --git a/src/auto/XSTools/OSL/Net/Win32/Socket.cpp b/src/auto/XSTools/OSL/Net/Win32/Socket.cpp index f05a07b0c4..c8e6e4b739 100644 --- a/src/auto/XSTools/OSL/Net/Win32/Socket.cpp +++ b/src/auto/XSTools/OSL/Net/Win32/Socket.cpp @@ -103,7 +103,7 @@ namespace _Intern { virtual void close() { if (!closed) { - shutdown(fd, SD_RECEIVE); + shutdown(fd, SD_SEND); closed = true; } } @@ -127,6 +127,8 @@ namespace _Intern { WinSocket::WinSocket(const char *address, unsigned short port) { + in = NULL; + out = NULL; fd = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP); if (fd == INVALID_SOCKET) { char message[100]; @@ -165,23 +167,49 @@ namespace _Intern { throw SocketException(message, error); } - in = new InStream(fd); - out = new OutStream(fd); + construct(fd); } WinSocket::WinSocket(SOCKET sock) { assert(sock != INVALID_SOCKET); - fd = sock; - in = new InStream(fd); - out = new OutStream(fd); + in = NULL; + out = NULL; + construct(sock); + } + + void + WinSocket::construct(SOCKET fd) { + this->fd = fd; + try { + in = new InStream(fd); + out = new OutStream(fd); + } catch (...) { + if (in != NULL) { + in->unref(); + in = NULL; + } + if (out != NULL) { + out->unref(); + out = NULL; + } + closesocket(fd); + this->fd = INVALID_SOCKET; + throw; + } } WinSocket::~WinSocket() { - in->close(); - in->unref(); - out->close(); - out->unref(); - closesocket(fd); + if (in != NULL) { + in->close(); + in->unref(); + } + if (out != NULL) { + out->close(); + out->unref(); + } + if (fd != INVALID_SOCKET) { + closesocket(fd); + } } InputStream * diff --git a/src/auto/XSTools/OSL/Net/Win32/Socket.h b/src/auto/XSTools/OSL/Net/Win32/Socket.h index 3f7cc95dab..2d74a7090c 100644 --- a/src/auto/XSTools/OSL/Net/Win32/Socket.h +++ b/src/auto/XSTools/OSL/Net/Win32/Socket.h @@ -43,6 +43,7 @@ namespace _Intern { SOCKET fd; InStream *in; OutStream *out; + void construct(SOCKET fd); public: /** diff --git a/src/auto/XSTools/OSL/Threading/Unix/Thread.cpp b/src/auto/XSTools/OSL/Threading/Unix/Thread.cpp index 116bc1d467..43613400d0 100644 --- a/src/auto/XSTools/OSL/Threading/Unix/Thread.cpp +++ b/src/auto/XSTools/OSL/Threading/Unix/Thread.cpp @@ -51,11 +51,13 @@ class PosixThread: public ThreadImplementation { throw ThreadException("Cannot initialize pthread attribute."); } if (pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) != 0) { + pthread_attr_destroy(&attr); throw ThreadException("Cannot set pthread detach state."); } ref(); if (pthread_create(&thread, &attr, entry, this) != 0) { unref(); + pthread_attr_destroy(&attr); throw ThreadException("Cannot create a thread."); } pthread_attr_destroy(&attr); diff --git a/src/auto/XSTools/PathFinding/PathFinding.xs b/src/auto/XSTools/PathFinding/PathFinding.xs index eda9dfba51..9cae33afad 100644 --- a/src/auto/XSTools/PathFinding/PathFinding.xs +++ b/src/auto/XSTools/PathFinding/PathFinding.xs @@ -212,8 +212,6 @@ PathFinding__reset(session, weight_map, avoidWalls, customWeights, secondWeightM session->customWeights = (unsigned short) SvUV (customWeights); session->time_max = (unsigned int) SvUV (time_max); - CalcPath_init(session); - if (session->customWeights) { /* secondWeightMap should be a reference to an array */ if (!SvROK(secondWeightMap)) { @@ -343,18 +341,52 @@ PathFinding__reset(session, weight_map, avoidWalls, customWeights, secondWeightM printf("[pathfinding reset error] [secondWeightMap] member of array 'weight' key is not defined\n"); XSRETURN_NO; } + } + } else { + if (SvOK(secondWeightMap)) { + printf("[pathfinding reset error] secondWeightMap is defined while customWeights is 0\n"); + XSRETURN_NO; + } + } + + CalcPath_init(session); + + if (session->customWeights) { + AV *deref_secondWeightMap; + I32 array_len; + + deref_secondWeightMap = (AV *) SvRV (secondWeightMap); + array_len = av_len (deref_secondWeightMap); + + SV **fetched; + HV *hash; + + SV **ref_x; + SV **ref_y; + SV **ref_weight; + + IV x; + IV y; + + I32 index; + + for (index = 0; index <= array_len; index++) { + fetched = av_fetch (deref_secondWeightMap, index, 0); + hash = (HV*) SvRV(*fetched); + ref_x = hv_fetch(hash, "x", 1, 0); + x = SvIV(*ref_x); + + ref_y = hv_fetch(hash, "y", 1, 0); + y = SvIV(*ref_y); + + ref_weight = hv_fetch(hash, "weight", 6, 0); unsigned int weight = SvIV(*ref_weight); long current = (y * session->width) + x; session->second_weight_map[current] = weight; } - } else { - if (SvOK(secondWeightMap)) { - printf("[pathfinding reset error] secondWeightMap is defined while customWeights is 0\n"); - XSRETURN_NO; - } } int diff --git a/src/auto/XSTools/Translation/translator.cpp b/src/auto/XSTools/Translation/translator.cpp index dff29a887e..2532395a1c 100644 --- a/src/auto/XSTools/Translation/translator.cpp +++ b/src/auto/XSTools/Translation/translator.cpp @@ -15,28 +15,36 @@ #include Translator::Translator (const char *filename) { + FileReader *loadedReader; + #ifdef WIN32 - reader = new WinFileReader (filename); + loadedReader = new WinFileReader (filename); #else - reader = new UnixFileReader (filename); + loadedReader = new UnixFileReader (filename); #endif // Sanity check file size. - if (reader->getSize () < TRANSLATION_TABLE_POINTER_OFFSET) + if (loadedReader->getSize () < TRANSLATION_TABLE_POINTER_OFFSET) { + delete loadedReader; throw 0; + } // Load pointer info. - count = reader-> + count = loadedReader-> readInt (COUNT_OFFSET); - origTableOffset = reader-> + origTableOffset = loadedReader-> readInt (ORIG_TABLE_POINTER_OFFSET); - translationTableOffset = reader-> + translationTableOffset = loadedReader-> readInt (TRANSLATION_TABLE_POINTER_OFFSET); // Further sanity check file size. - if (reader->getSize () < origTableOffset - || reader->getSize () < translationTableOffset) + if (loadedReader->getSize () < origTableOffset + || loadedReader->getSize () < translationTableOffset) { + delete loadedReader; throw 1; + } + + reader = loadedReader; } Translator::~Translator () diff --git a/src/auto/XSTools/Translation/winfilereader.cpp b/src/auto/XSTools/Translation/winfilereader.cpp index 2a3904b89f..313fb39bba 100644 --- a/src/auto/XSTools/Translation/winfilereader.cpp +++ b/src/auto/XSTools/Translation/winfilereader.cpp @@ -6,6 +6,9 @@ WinFileReader::WinFileReader (const char *filename) { OFSTRUCT buf; + hFile = INVALID_HANDLE_VALUE; + hMapFile = NULL; + addr = NULL; buf.cBytes = sizeof (OFSTRUCT); hFile = CreateFile (filename, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); @@ -30,8 +33,12 @@ WinFileReader::WinFileReader (const char *filename) WinFileReader::~WinFileReader () { - CloseHandle (hMapFile); - CloseHandle (hFile); + if (addr != NULL) + UnmapViewOfFile (addr); + if (hMapFile != NULL) + CloseHandle (hMapFile); + if (hFile != INVALID_HANDLE_VALUE) + CloseHandle (hFile); } unsigned int diff --git a/src/auto/XSTools/utils/perl/Benchmark.xs b/src/auto/XSTools/utils/perl/Benchmark.xs index f74944b017..cab36c6192 100644 --- a/src/auto/XSTools/utils/perl/Benchmark.xs +++ b/src/auto/XSTools/utils/perl/Benchmark.xs @@ -55,7 +55,7 @@ public: delete *it; } for (it2 = domains.begin(); it2 != domains.end(); it2++) { - delete *it2; + free(*it2); } } diff --git a/src/auto/XSTools/utils/unix/http-reader.cpp b/src/auto/XSTools/utils/unix/http-reader.cpp index c55ea96c6e..39e7343056 100644 --- a/src/auto/XSTools/utils/unix/http-reader.cpp +++ b/src/auto/XSTools/utils/unix/http-reader.cpp @@ -132,6 +132,9 @@ class Private: public HttpReader { public: Private(const char *url, const char *postData, int postDataSize, const char *userAgent) { + pthread_attr_t attr; + bool attrInitialized = false; + refCount = 1; this->url = NULL; this->postData = NULL; @@ -148,11 +151,11 @@ class Private: public HttpReader { mutexInitialized = true; } - pthread_attr_t attr; if (pthread_attr_init(&attr) != 0) { error = "Cannot create thread attribute object."; return; } + attrInitialized = true; if (pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) != 0) { pthread_attr_destroy(&attr); error = "Cannot set thread attribute to detached."; @@ -169,6 +172,7 @@ class Private: public HttpReader { memcpy(this->postData, postData, postDataSize); this->postDataSize = postDataSize; } else { + pthread_attr_destroy(&attr); error = "Cannot allocate memory for POST data."; return; } @@ -181,11 +185,13 @@ class Private: public HttpReader { ref(); if (pthread_create(&thread, &attr, threadEntry, this) != 0) { unref(); - pthread_attr_destroy(&attr); status = HTTP_READER_ERROR; error = "Cannot create a thread."; size = -2; } + if (attrInitialized) { + pthread_attr_destroy(&attr); + } } ~Private() { diff --git a/src/auto/XSTools/utils/win32/http-reader.cpp b/src/auto/XSTools/utils/win32/http-reader.cpp index c45978055d..c376d26611 100644 --- a/src/auto/XSTools/utils/win32/http-reader.cpp +++ b/src/auto/XSTools/utils/win32/http-reader.cpp @@ -320,6 +320,9 @@ class WinHttpReader: public StdHttpReader { } if (this->postData != NULL) free(this->postData); + if (errorMustBeFreed && error != NULL) + free(error); + DeleteCriticalSection(&lock); } virtual HttpReaderStatus diff --git a/src/functions.pl b/src/functions.pl index 33a5e7faa6..eb5e99440e 100644 --- a/src/functions.pl +++ b/src/functions.pl @@ -488,6 +488,68 @@ sub initNetworking { Network::PaddedPackets::init(); } +sub _trashCorruptedPortalsLOS { + my $portals_los_file = Settings::getTableFilename("portalsLOS.txt"); + return 0 unless defined $portals_los_file && -f $portals_los_file; + + open my $fh, '<:raw', $portals_los_file or do { + warning TF("Unable to inspect '%s' for portal LOS corruption: %s\n", $portals_los_file, $!); + return 0; + }; + + my $line_number = 0; + my $corrupted_reason; + while (my $line = <$fh>) { + $line_number++; + $line =~ s/\x{FEFF}//g; + next if $line =~ /^#/; + $line =~ s/[\r\n]//g; + $line =~ s/\s+/ /g; + $line =~ s/^\s+|\s+$//g; + next if $line eq ''; + + my @args = split /\s/, $line; + if (@args < 7) { + $corrupted_reason = "too few tokens"; + last; + } + if ((@args - 3) % 4 != 0) { + $corrupted_reason = "destination tuple count is not divisible by 4"; + last; + } + if ($args[1] !~ /^\d+$/ || $args[2] !~ /^\d+$/) { + $corrupted_reason = "source coordinates are not numeric"; + last; + } + + for (my $i = 3; $i < @args; $i += 4) { + if ($args[$i + 1] !~ /^\d+$/ || $args[$i + 2] !~ /^\d+$/) { + $corrupted_reason = "destination coordinates are not numeric"; + last; + } + if ($args[$i + 3] !~ /^-?\d+$/) { + $corrupted_reason = "LOS distance is not numeric"; + last; + } + } + last if $corrupted_reason; + } + close $fh; + + return 0 unless $corrupted_reason; + + warning TF("Detected corrupted entry in '%s' at line %s (%s). Removing the file so portals can be recompiled from scratch.\n", + $portals_los_file, $line_number, $corrupted_reason); + + undef %portals_los; + if (!unlink $portals_los_file) { + warning TF("Unable to remove corrupted portal LOS file '%s': %s. A rebuild will still overwrite it.\n", + $portals_los_file, $!); + } + + return 1; +} + sub initPortalsDatabase { # $config{portalCompile} # -1: skip compile @@ -498,8 +560,10 @@ sub initPortalsDatabase { return if $config{portalCompile} < 0; + my $discarded_corrupted_portals_los = _trashCorruptedPortalsLOS(); + Log::message(T("Checking for new portals... ")); - if (compilePortals_check()) { + if ($discarded_corrupted_portals_los || compilePortals_check()) { Log::message(T("found new portals!\n")); my $choice = $config{portalCompile} ? 0 : $interface->showMenu( T("New portals have been added to the portals database. " . @@ -1029,35 +1093,45 @@ sub mainLoop_initialized { Misc::checkValidity("mainLoop_part2.4"); - # Set interface title - my $charName; - my $title; - $charName = "$char->{name}: " if ($char); - if ($net->getState() == Network::IN_GAME) { - my ($basePercent, $jobPercent, $weight, $pos); - - assert(defined $char); - $basePercent = sprintf("%.2f", $char->exp_base_percent); - $jobPercent = sprintf("%.2f",$char->exp_job_percent); - $weight = int($char->weight_percent) . "%"; - $pos = " : $char->{pos_to}{x},$char->{pos_to}{y} " . $field->name if ($char->{pos_to} && $field); - my $aiSeq = join(",", @ai_seq); - # Translation Comment: Interface Title with character status - $title = TF("%s B%s (%s), J%s (%s) : w%s%s [%s] - %s", - $charName, $char->{lv}, $basePercent . '%', - $char->{lv_job}, $jobPercent . '%', - $weight, $pos, $aiSeq, $Settings::NAME); - - } elsif ($net->getState() == Network::NOT_CONNECTED) { - # Translation Comment: Interface Title - $title = TF("%sNot connected - %s", $charName, $Settings::NAME); - } else { - # Translation Comment: Interface Title - $title = TF("%sConnecting - %s", $charName, $Settings::NAME); + if (timeOut($timeout{setTitle})) { + $timeout{setTitle}{time} = time; + + # Set interface title + my $charName; + my $title; + $charName = "$char->{name}: " if ($char); + if ($net->getState() == Network::IN_GAME) { + my ($basePercent, $jobPercent, $weight, $pos); + + assert(defined $char); + $basePercent = sprintf("%.2f", $char->exp_base_percent); + $jobPercent = sprintf("%.2f",$char->exp_job_percent); + $weight = int($char->weight_percent) . "%"; + $pos = " : $char->{pos_to}{x},$char->{pos_to}{y} " . $field->name if ($char->{pos_to} && $field); + my $aiSeq = join(",", @ai_seq); + # Translation Comment: Interface Title with character status + $title = TF("%s B%s (%s), J%s (%s) : w%s%s [%s] - %s", + $charName, $char->{lv}, $basePercent . '%', + $char->{lv_job}, $jobPercent . '%', + $weight, $pos, $aiSeq, $Settings::NAME); + + } elsif ($net->getState() == Network::NOT_CONNECTED) { + # Translation Comment: Interface Title + $title = TF("%sNot connected - %s", $charName, $Settings::NAME); + } else { + # Translation Comment: Interface Title + $title = TF("%sConnecting - %s", $charName, $Settings::NAME); + } + + my %args = (return => $title); + Plugins::callHook('mainLoop::setTitle',\%args); + + if (%ai_v && exists $ai_v{temp} && exists $ai_v{temp}{lastTitle} && defined $ai_v{temp}{lastTitle} && $ai_v{temp}{lastTitle} eq $args{return}) { + # Title is the same as last time, skip setting it again to avoid unnecessary overhead. + return; + } + $interface->title($args{return}); } - my %args = (return => $title); - Plugins::callHook('mainLoop::setTitle',\%args); - $interface->title($args{return}); Misc::checkValidity("mainLoop_part3"); Benchmark::end("mainLoop_part3") if DEBUG; diff --git a/src/test/CastConditionsTest.pm b/src/test/CastConditionsTest.pm new file mode 100644 index 0000000000..f58473c8aa --- /dev/null +++ b/src/test/CastConditionsTest.pm @@ -0,0 +1,285 @@ +package CastConditionsTest; + +use strict; +use FindBin qw($RealBin); +use Test::More; +use Globals; +use ActorList; +use Actor::You; +use Actor::Player; +use Actor::Monster; +use Misc; +use Skill; + +sub start { + note('Starting ' . __PACKAGE__); + __PACKAGE__->new->run; +} + +sub new { + return bless {}, $_[0]; +} + +sub run { + my ($self) = @_; + + Skill::StaticInfo::parseSkillsDatabase_id2handle("$RealBin/SKILL_id_handle.txt"); + Skill::StaticInfo::parseSkillsDatabase_handle2name("$RealBin/skillnametable.txt"); + Skill::DynamicInfo::add(288, 'HP_ASSUMPTIO', 5, 30, 9, Skill::TARGET_ACTORS(), Skill::OWNER_CHAR()); + Skill::DynamicInfo::add(931, 'MER_DECAGI', 10, 15, 9, Skill::TARGET_ENEMY(), Skill::OWNER_CHAR()); + Skill::DynamicInfo::add(777, 'PR_MAGNIFICAT', 5, 40, 9, Skill::TARGET_SELF(), Skill::OWNER_CHAR()); + + $self->testSelfConditionBlocksWhileBeingCasted; + $self->testSelfConditionRequiresWhileBeingCasted; + $self->testSelfConditionBlocksWhileNearPartyMemberCasting; + $self->testSelfConditionRequiresNearPartyMemberCasting; + $self->testPlayerConditionBlocksPartyTargetCast; + $self->testPlayerConditionRequiresWhileBeingCasted; + $self->testMonsterConditionBlocksMonsterTargetCast; + $self->testMonsterConditionRequiresWhileBeingCasted; +} + +sub testSelfConditionBlocksWhileBeingCasted { + my $char = _fresh_char(); + my $caster = _player(2); + $caster->{casting} = { + skill => Skill->new(auto => 'Blessing'), + targetID => $char->{ID}, + target => $char, + }; + $Globals::playersList->add($caster); + + local %Globals::config = ( + selftest_manualAI => 2, + selftest_notWhileBeingCasted => 'Blessing', + ); + local %Misc::config = %Globals::config; + + ok(!Misc::checkSelfCondition('selftest'), 'self condition fails while blessing is being cast on self'); + + $Globals::config{selftest_notWhileBeingCasted} = 'Assumption'; + $Misc::config{selftest_notWhileBeingCasted} = 'Assumption'; + ok(Misc::checkSelfCondition('selftest'), 'self condition ignores other skills being cast on self'); +} + +sub testSelfConditionRequiresWhileBeingCasted { + my $char = _fresh_char(); + my $caster = _player(9); + $caster->{casting} = { + skill => Skill->new(auto => 'Blessing'), + targetID => $char->{ID}, + target => $char, + }; + $Globals::playersList->add($caster); + + local %Globals::config = ( + selftest_manualAI => 2, + selftest_whileBeingCasted => 'Blessing', + ); + local %Misc::config = %Globals::config; + + ok(Misc::checkSelfCondition('selftest'), 'self condition passes while the requested skill is being cast on self'); + + $Globals::config{selftest_whileBeingCasted} = 'Assumption'; + $Misc::config{selftest_whileBeingCasted} = 'Assumption'; + ok(!Misc::checkSelfCondition('selftest'), 'self condition fails when self is not being casted with the requested skill'); +} + +sub testSelfConditionBlocksWhileNearPartyMemberCasting { + my $char = _fresh_char(); + my $party_caster = _player(7); + my $outsider = _player(8); + $party_caster->{casting} = { + skill => Skill->new(auto => 'PR_MAGNIFICAT'), + targetID => $party_caster->{ID}, + target => $party_caster, + }; + $outsider->{casting} = { + skill => Skill->new(auto => 'PR_MAGNIFICAT'), + targetID => $outsider->{ID}, + target => $outsider, + }; + $Globals::playersList->add($party_caster); + $Globals::playersList->add($outsider); + $char->{party}{joined} = 1; + $char->{party}{users}{$party_caster->{ID}} = {online => 1}; + + local %Globals::config = ( + selftest_manualAI => 2, + selftest_whenNoNearPartyMemberCasting => 'PR_MAGNIFICAT', + ); + local %Misc::config = %Globals::config; + + ok(!Misc::checkSelfCondition('selftest'), 'self condition fails while a nearby party member is casting the same skill'); + + delete $party_caster->{casting}; + ok(Misc::checkSelfCondition('selftest'), 'self condition ignores non-party players casting the same skill'); +} + +sub testSelfConditionRequiresNearPartyMemberCasting { + my $char = _fresh_char(); + my $party_caster = _player(10); + my $outsider = _player(11); + $party_caster->{casting} = { + skill => Skill->new(auto => 'PR_MAGNIFICAT'), + targetID => $party_caster->{ID}, + target => $party_caster, + }; + $outsider->{casting} = { + skill => Skill->new(auto => 'PR_MAGNIFICAT'), + targetID => $outsider->{ID}, + target => $outsider, + }; + $Globals::playersList->add($party_caster); + $Globals::playersList->add($outsider); + $char->{party}{joined} = 1; + $char->{party}{users}{$party_caster->{ID}} = {online => 1}; + + local %Globals::config = ( + selftest_manualAI => 2, + selftest_whenNearPartyMemberCasting => 'PR_MAGNIFICAT', + ); + local %Misc::config = %Globals::config; + + ok(Misc::checkSelfCondition('selftest'), 'self condition passes while a nearby party member is casting the requested skill'); + + delete $party_caster->{casting}; + ok(!Misc::checkSelfCondition('selftest'), 'self condition fails when no nearby party member is casting the requested skill'); +} + +sub testPlayerConditionBlocksPartyTargetCast { + my $char = _fresh_char(); + my $target = _player(3); + my $caster = _player(4); + $caster->{casting} = { + skill => Skill->new(auto => 'HP_ASSUMPTIO'), + targetID => $target->{ID}, + target => $target, + }; + $Globals::playersList->add($target); + $Globals::playersList->add($caster); + + local %Globals::config = ( + playertest_target_notWhileBeingCasted => 'HP_ASSUMPTIO', + ); + local %Misc::config = %Globals::config; + + ok(!Misc::checkPlayerCondition('playertest_target', $target->{ID}), 'player condition fails while target is already receiving assumption'); + + $Globals::config{playertest_target_notWhileBeingCasted} = 'Blessing'; + $Misc::config{playertest_target_notWhileBeingCasted} = 'Blessing'; + ok(Misc::checkPlayerCondition('playertest_target', $target->{ID}), 'player condition allows target when a different skill is being cast'); +} + +sub testPlayerConditionRequiresWhileBeingCasted { + my $char = _fresh_char(); + my $target = _player(12); + my $caster = _player(13); + $caster->{casting} = { + skill => Skill->new(auto => 'HP_ASSUMPTIO'), + targetID => $target->{ID}, + target => $target, + }; + $Globals::playersList->add($target); + $Globals::playersList->add($caster); + + local %Globals::config = ( + playertest_target_whileBeingCasted => 'HP_ASSUMPTIO', + ); + local %Misc::config = %Globals::config; + + ok(Misc::checkPlayerCondition('playertest_target', $target->{ID}), 'player condition passes while target is receiving the requested cast'); + + $Globals::config{playertest_target_whileBeingCasted} = 'Blessing'; + $Misc::config{playertest_target_whileBeingCasted} = 'Blessing'; + ok(!Misc::checkPlayerCondition('playertest_target', $target->{ID}), 'player condition fails when target is not receiving the requested cast'); +} + +sub testMonsterConditionBlocksMonsterTargetCast { + my $char = _fresh_char(); + my $monster = _monster(5); + my $caster = _player(6); + $caster->{casting} = { + skill => Skill->new(auto => 'MER_DECAGI'), + targetID => $monster->{ID}, + target => $monster, + }; + $Globals::monstersList->add($monster); + $Globals::playersList->add($caster); + + local %Globals::config = ( + monstertest_target_notWhileBeingCasted => 'MER_DECAGI', + ); + local %Misc::config = %Globals::config; + + ok(!Misc::checkMonsterCondition('monstertest_target', $monster), 'monster condition fails while the same debuff is already being cast'); + + $Globals::config{monstertest_target_notWhileBeingCasted} = 'Blessing'; + $Misc::config{monstertest_target_notWhileBeingCasted} = 'Blessing'; + ok(Misc::checkMonsterCondition('monstertest_target', $monster), 'monster condition allows the target when another skill is being cast'); +} + +sub testMonsterConditionRequiresWhileBeingCasted { + my $char = _fresh_char(); + my $monster = _monster(14); + my $caster = _player(15); + $caster->{casting} = { + skill => Skill->new(auto => 'MER_DECAGI'), + targetID => $monster->{ID}, + target => $monster, + }; + $Globals::monstersList->add($monster); + $Globals::playersList->add($caster); + + local %Globals::config = ( + monstertest_target_whileBeingCasted => 'MER_DECAGI', + ); + local %Misc::config = %Globals::config; + + ok(Misc::checkMonsterCondition('monstertest_target', $monster), 'monster condition passes while the requested debuff is being cast'); + + $Globals::config{monstertest_target_whileBeingCasted} = 'Blessing'; + $Misc::config{monstertest_target_whileBeingCasted} = 'Blessing'; + ok(!Misc::checkMonsterCondition('monstertest_target', $monster), 'monster condition fails when the requested debuff is not being cast'); +} + +sub _fresh_char { + my $char = Actor::You->new; + $char->{ID} = pack('V', 1); + $Globals::char = $char; + $Misc::char = $char; + _reset_lists(); + return $char; +} + +sub _reset_lists { + $Globals::playersList = ActorList->new('Actor::Player'); + $Globals::monstersList = ActorList->new('Actor::Monster'); + $Globals::npcsList = ActorList->new('Actor::NPC'); + $Globals::petsList = ActorList->new('Actor::Pet'); + $Globals::slavesList = ActorList->new('Actor::Slave'); + $Globals::elementalsList = ActorList->new('Actor::Elemental'); + + $Misc::playersList = $Globals::playersList; + $Misc::monstersList = $Globals::monstersList; + $Misc::npcsList = $Globals::npcsList; + $Misc::petsList = $Globals::petsList; + $Misc::slavesList = $Globals::slavesList; + $Misc::elementalsList = $Globals::elementalsList; +} + +sub _player { + my ($id_num) = @_; + my $player = Actor::Player->new; + $player->{ID} = pack('V', $id_num); + return $player; +} + +sub _monster { + my ($id_num) = @_; + my $monster = Actor::Monster->new; + $monster->{ID} = pack('V', $id_num); + return $monster; +} + +1; diff --git a/src/test/Distfiles b/src/test/Distfiles index 342dfb59fa..325dfe5b01 100644 --- a/src/test/Distfiles +++ b/src/test/Distfiles @@ -32,6 +32,7 @@ SKILL_id_handle.txt skillnametable.txt skillssp.txt SkillTest.pm +TeleportFallbackTest.pm TaskChainedTest.pm TaskManagerTest.pm TaskTalkNPCTest.pm diff --git a/src/test/TeleportFallbackTest.pm b/src/test/TeleportFallbackTest.pm new file mode 100644 index 0000000000..20d04150bd --- /dev/null +++ b/src/test/TeleportFallbackTest.pm @@ -0,0 +1,154 @@ +package TeleportFallbackTest; + +use strict; + +use FindBin qw($RealBin); +use Test::More; +use Globals; +use Misc; +use Skill; +use Actor::You; +use Actor::Item; +use Task::Teleport::Random; +use Task::Teleport::Respawn; + +sub start { + note('Starting ' . __PACKAGE__); + Skill::StaticInfo::parseSkillsDatabase_id2handle("$RealBin/../../tables/SKILL_id_handle.txt"); + Skill::StaticInfo::parseSkillsDatabase_handle2name("$RealBin/../../tables/Old/skillnametable.txt"); + Skill::StaticInfo::parseSPDatabase("$RealBin/../../tables/Old/skillssp.txt"); + + __PACKAGE__->new->run; +} + +sub new { + return bless {}, $_[0]; +} + +sub run { + my ($self) = @_; + + $self->testRandomTeleportFallsBackToItemWhenSPIsTooLow; + $self->testRespawnTeleportFallsBackToItemWhenSPIsTooLow; + $self->testSharedTeleportCheckRejectsSkillWithoutSP; + $self->testMutedBlocksTeleportSkillChecks; + $self->testSilenceBlocksTeleportSkillChecks; +} + +sub testRandomTeleportFallsBackToItemWhenSPIsTooLow { + my $char = _fresh_char(); + $char->{skills}{AL_TELEPORT}{lv} = 1; + $char->{sp} = 5; + $char->inventory->add(_item(601, 101, 'Fly Wing')); + + my $task = Task::Teleport::Random->new(actor => $char); + + ok(!$task->canUseSkill, 'random teleport skill is not considered usable without enough SP'); + ok($task->getInventoryItem, 'random teleport still finds a teleport item fallback'); + ok(Misc::canUseTeleport(1), 'shared random teleport check still allows teleport via item fallback'); +} + +sub testRespawnTeleportFallsBackToItemWhenSPIsTooLow { + my $char = _fresh_char(); + $char->{skills}{AL_TELEPORT}{lv} = 2; + $char->{sp} = 5; + $char->inventory->add(_item(602, 102, 'Butterfly Wing')); + + my $task = Task::Teleport::Respawn->new(actor => $char); + + ok(!$task->canUseSkill, 'respawn teleport skill is not considered usable without enough SP'); + ok($task->getInventoryItem, 'respawn teleport still finds a teleport item fallback'); + ok(Misc::canUseTeleport(2), 'shared respawn teleport check still allows teleport via item fallback'); +} + +sub testSharedTeleportCheckRejectsSkillWithoutSP { + my $char = _fresh_char(); + $char->{skills}{AL_TELEPORT}{lv} = 2; + $char->{sp} = 5; + + ok(!Misc::canUseTeleport(1), 'shared random teleport check rejects skill-only teleport without enough SP'); + ok(!Misc::canUseTeleport(2), 'shared respawn teleport check rejects skill-only teleport without enough SP'); + + $char->{sp} = 20; + ok(Misc::canUseTeleport(1), 'shared random teleport check accepts skill teleport once SP is sufficient'); + ok(Misc::canUseTeleport(2), 'shared respawn teleport check accepts skill teleport once SP is sufficient'); +} + +sub testMutedBlocksTeleportSkillChecks { + my $char = _fresh_char(); + $char->{skills}{AL_TELEPORT}{lv} = 2; + $char->{sp} = 20; + $char->{muted} = 1; + + ok(!Misc::canUseTeleport(1), 'shared random teleport check rejects skill teleport while muted'); + ok(!Misc::canUseTeleport(2), 'shared respawn teleport check rejects skill teleport while muted'); + + $char->inventory->add(_item(601, 103, 'Fly Wing')); + $char->inventory->add(_item(602, 104, 'Butterfly Wing')); + ok(Misc::canUseTeleport(1), 'shared random teleport check still allows item teleport while muted'); + ok(Misc::canUseTeleport(2), 'shared respawn teleport check still allows item teleport while muted'); +} + +sub testSilenceBlocksTeleportSkillChecks { + my $char = _fresh_char(); + $char->{skills}{AL_TELEPORT}{lv} = 2; + $char->{sp} = 20; + $char->{statuses}{HEALTHSTATE_SILENCE} = 1; + + my $randomTask = Task::Teleport::Random->new(actor => $char); + my $respawnTask = Task::Teleport::Respawn->new(actor => $char); + + ok(!$randomTask->canUseSkill, 'random teleport skill is rejected while HEALTHSTATE_SILENCE is active'); + ok(!$respawnTask->canUseSkill, 'respawn teleport skill is rejected while HEALTHSTATE_SILENCE is active'); + ok(!Misc::canUseTeleport(1), 'shared random teleport check rejects skill teleport while silenced'); + ok(!Misc::canUseTeleport(2), 'shared respawn teleport check rejects skill teleport while silenced'); + + $char->inventory->add(_item(601, 105, 'Fly Wing')); + $char->inventory->add(_item(602, 106, 'Butterfly Wing')); + ok(Misc::canUseTeleport(1), 'shared random teleport check still allows item teleport while silenced'); + ok(Misc::canUseTeleport(2), 'shared respawn teleport check still allows item teleport while silenced'); +} + +sub _fresh_char { + my $char = Actor::You->new; + $char->{ID} = pack('V', 1); + $char->{sp} = 0; + $char->{muted} = 0; + $char->inventory->{state} = 1; + + %Globals::config = (); + $Globals::char = $char; + $Globals::field = bless { baseName => 'prt_fild05' }, 'TeleportFallbackTest::Field'; + $Globals::net = bless {}, 'TeleportFallbackTest::Net'; + + $Misc::char = $Globals::char; + $Misc::field = $Globals::field; + $Misc::net = $Globals::net; + %Misc::config = (); + + return $char; +} + +sub _item { + my ($nameID, $id_num, $name) = @_; + + my $item = Actor::Item->new; + $item->{ID} = pack('V', $id_num); + $item->{nameID} = $nameID; + $item->{name} = $name; + return $item; +} + +package TeleportFallbackTest::Field; + +sub baseName { + return $_[0]{baseName}; +} + +package TeleportFallbackTest::Net; + +sub getState { + return Network::IN_GAME; +} + +1; diff --git a/src/test/Utils/DataWaitingTest.pm b/src/test/Utils/DataWaitingTest.pm new file mode 100644 index 0000000000..9914e9ebe8 --- /dev/null +++ b/src/test/Utils/DataWaitingTest.pm @@ -0,0 +1,48 @@ +package Utils::DataWaitingTest; + +use strict; +use Test::More; +use IO::Socket::INET; + +use Utils qw(dataWaiting); + +sub start { + subtest 'undefined handles return false' => sub { + my $undefined; + is(dataWaiting(undef), 0, 'missing handle reference returns false'); + is(dataWaiting(\$undefined), 0, 'undefined handle returns false'); + done_testing(); + }; + + subtest 'closed sockets return false without dying' => sub { + my $socket = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Proto => 'tcp', + Listen => 1, + Reuse => 1, + ); + ok($socket, 'created test socket') or do { + done_testing(); + return; + }; + close($socket); + + my ($result, $error); + eval { $result = dataWaiting(\$socket); 1 } or $error = $@; + is($error, undef, 'closed socket does not throw'); + is($result, 0, 'closed socket reports no pending data'); + done_testing(); + }; + + subtest 'non-handle objects return false without dying' => sub { + my $not_a_handle = bless {}, 'Utils::DataWaitingTest::FakeHandle'; + my ($result, $error); + eval { $result = dataWaiting(\$not_a_handle); 1 } or $error = $@; + is($error, undef, 'non-handle object does not throw'); + is($result, 0, 'non-handle object reports no pending data'); + done_testing(); + }; +} + +1; diff --git a/src/test/unittests.pl b/src/test/unittests.pl index f5e9e194eb..eb3eef2b0a 100755 --- a/src/test/unittests.pl +++ b/src/test/unittests.pl @@ -13,10 +13,13 @@ my @tests = qw( Utils::TextReaderTest + Utils::DataWaitingTest CallbackListTest ObjectListTest ActorListTest WhirlpoolTest RijndaelTest SetTest SkillTest InventoryListTest ItemsTest HandConditionsTest + CastConditionsTest + TeleportFallbackTest ShopTest TaskManagerTest TaskWithSubtaskTest TaskChainedTest TaskTalkNPCTest diff --git a/tables/npc_shops.txt b/tables/npc_shops.txt index f3810141c5..ee9bf354d4 100644 --- a/tables/npc_shops.txt +++ b/tables/npc_shops.txt @@ -9,7 +9,7 @@ npcmap,npcx,npcy,item1id:item1price,item2id:item2price,etc 1@gef_in,30,178,1750:1,1751:5,1101:100,1701:1000,1201:50,1204:1250,1207:2400,1210:8500,1213:14000,1216:19500,1601:50,1604:2500,1607:9500,1610:45000 1@gef,193,152,537:1000,643:10000,10013:1500,10014:2000,6113:1000,6114:1000,6115:1000,6110:1000,6100:1000,6098:1000,6112:1000,6104:1000,6108:1000,6111:1000,7824:1000,6097:1000,6107:1000 1@gef,196,111,7482:375 -alb2trea,87,65,1750:1,1751:5,1752:5,501:10,502:50,503:180,504:1200,506:40,645:1200,656:2200 +alb2trea,87,65,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 alberta_in,165,96,911:120,528:60 alberta_in,182,97,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 alberta_in,180,15,2101:500,2103:14000,2401:400,2403:3500,2405:18000,2501:1000,2503:5000,2505:32000,2203:4000,2201:5000,2205:3500,2226:12000,2301:10,2303:200,2305:1000,2321:8000,2328:5500,2332:7000,2307:10000,2309:22000,2312:48000,2314:65000,2628:400 @@ -32,7 +32,7 @@ cmd_in01,117,165,2226:12000,2228:44000,2103:14000,2105:56000,2405:18000,2503:500 cmd_in01,128,165,1901:4000,1903:18000,1905:24500,1909:62000,1911:54000,1907:47000,1950:2500,1952:12000,1954:17500,1958:41000,1960:38000,1956:32000 cmd_in01,79,182,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 comodo,296,125,965:56,964:90 -cmd_fild07,257,126,1770:3,501:10,502:50,503:180,504:1200,645:1200,656:2200,601:250,602:1000,611:40,1065:50 +cmd_fild07,257,126,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 cmd_fild07,250,98,1401:150,1404:1700,1407:3450,1451:13000,1454:20000,1457:27000,1460:51000,1463:54000,1410:60000 cmd_fild07,277,85,2226:12000,2228:44000,2103:14000,2105:56000,2405:18000,2503:5000,2505:32000,2305:1000,2321:8000,2307:10000,2309:22000,2335:74000,2312:48000,2314:65000,2316:80000 ein_in01,106,27,1101:100,1104:1500,1107:2900,1110:10000,1113:17000,1119:51000,1122:24000,1123:50000,1126:49000,1129:60000,1201:50,1204:1250,1207:2400,1210:8500,1213:14000,1216:19500,1219:43000,1222:49000,1301:500,1401:150,1404:1700,1407:3450,1501:120,1504:1600,1507:9000,1510:16000,1513:41000,1516:50000,1519:23000,1522:60000,1801:8000,1803:25000,1805:32000,1807:53000,1809:67000,1811:58000 @@ -62,12 +62,12 @@ hu_in01,94,313,2224:20,2232:7500,2226:12000,2101:500,2103:14000,2401:400,2501:10 izlude_in,60,127,1101:100,1104:1500,1107:2900,1116:2000,1151:15000,1154:22500,1157:60000,1160:65000,1201:50,1204:1250,1207:2400,1301:500,1601:50,1701:1000 izlude_in,70,127,2103:14000,2105:56000,2403:3500,2405:18000,2503:5000,2505:32000,2226:12000,2228:44000,2303:200,2305:1000,2328:5500,2307:10000,2309:22000,2312:48000,2314:65000,2316:80000,2628:400 jawaii,186,174,536:150,536:150,536:150,536:150,536:150,536:150,536:150 -yuno,218,97,1750:1,611:40,501:10,502:50,503:180,504:1200,506:40,645:1200,656:2200,601:250,602:1000 +yuno,218,97,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 yuno,226,107,911:120,910:30,912:360 yuno,205,103,2340:89000,2341:94000,2411:48000,2222:4500,2230:50000,1721:89000 yuno,163,187,717:450,1601:50,1604:2500,1607:9500,1610:45000,2232:7500,2321:8000,2332:7000 -yuno_in01,25,34,1750:1,611:40,501:10,502:50,503:180,504:1200,506:40,645:1200,656:2200,601:250,602:1000 -yuno_in01,103,35,1750:1,1751:5,1101:100,1701:1000,1201:50,1204:1250,1207:2400,1210:8500,1213:14000,1216:19500,1601:50,1604:2500,1607:9500,1610:45000 +yuno_in01,25,34,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 +yuno_in01,103,35,1101:100,1201:50,1204:1250,1207:2400,1210:8500,1213:14000,1216:19500,1601:50,1604:2500,1607:9500,1610:45000,1701:1000 yuno_in01,112,26,2628:400,2101:500,2107:60000,2401:400,2501:1000,2230:50000,2301:10,2303:200,2305:1000,2321:8000,2332:7000 yuno_in03,176,22,7433:4000 lighthalzen,69,75,512:15,513:15 @@ -83,9 +83,9 @@ lhz_in02,21,220,512:15,513:15 lhz_in02,32,219,517:50 lhz_in02,38,145,734:2250,735:3750,736:750,737:300,746:1050 lhz_in02,47,148,744:1500,745:9000,2338:43000,2206:23000,7170:43000 -lhz_in02,31,145,611:40,503:180,504:1200,506:40,657:4500,656:2200,601:250,602:1000,1065:50,610:12000,1770:3 +lhz_in02,31,145,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 lhz_in02,85,216,740:1000,741:1800,742:3000 -lhz_in02,87,208,2243:20000,2212:1000,2242:24000,2241:5000 +lhz_in02,87,208,2212:1000,2241:5000,2242:24000,2243:20000 lhz_in03,239,106,501:10,502:50,506:40,645:1200,656:2200,601:250,602:1000,1065:50,611:40 lhz_in03,258,101,1750:1,1770:3,1752:5,1751:5 lhz_in03,249,24,911:120,910:30,912:360,528:60 @@ -106,7 +106,7 @@ moc_ruins,125,135,2609:10000,1516:50000,1522:60000 moc_ruins,87,109,517:50 moc_ruins,90,149,513:15,513:15,513:15,513:15,513:15,513:15 morocc_in,141,67,1201:50,1204:1250,1207:2400,1210:8500,1213:14000,1216:19500,1219:43000,1222:49000,1250:19500,1252:41000,1254:37200,1601:50,1701:1000 -morocc_in,141,60,2101:500,2103:14000,2401:400,2403:3500,2405:18000,2501:1000,2503:5000,2218:300,2301:10,2303:200,2305:1000,2321:8000,2328:5500,2332:7000,2307:10000,2309:22000,2335:74000,2628:400 +morocc_in,141,60,2101:500,2103:14000,2218:300,2301:10,2303:200,2305:1000,2307:10000,2309:22000,2321:8000,2328:5500,2332:7000,2335:74000,2401:400,2403:3500,2405:18000,2501:1000,2503:5000,2628:400 morocc_in,132,57,1146:42000,1245:40000 mosk_in,21,254,611:40,501:10,502:50,503:180,504:1200,506:40,645:1200,656:2200,601:250,602:1000,1065:50,1750:1 mosk_in,31,180,1207:2400,1216:19500,1107:2900,1122:24000,1116:2000,1154:22500,1407:3450,1457:27000,1354:15500,1519:23000 @@ -146,7 +146,7 @@ ra_in01,254,300,512:15,513:15 rachel,65,80,515:15,535:15,516:15 tur_dun01,158,54,1750:1,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,645:1200,656:2200,2242:24000 um_in,104,124,501:10,502:50,503:180,504:1200,506:40,601:250,602:1000,610:12000,611:40,645:1200,656:2200,657:4500,713:400,717:450,1771:50,23280:10,23288:1000 -um_in,160,125,1501:120,1504:1600,1507:9000,1510:16000,1513:41000,1519:23000,1807:53000,1811:58000,1809:67000 +um_in,160,125,1501:120,1504:1600,1507:9000,1510:16000,1513:41000,1519:23000,1807:53000,1809:67000,1811:58000 ve_in,386,245,1201:50,1207:2400,1216:19500,1107:2900,1122:24000,1116:2000,1154:22500 ve_in,336,243,1407:3450,1457:27000,1354:15500,1519:23000,13003:52000 ve_in,374,230,2101:500,2103:14000,2403:3500,2405:18000,2503:5000,2321:8000,2314:65000,2309:22000,2335:74000,2628:400 @@ -196,18 +196,13 @@ lighthalzen,222,191,537:1000,643:10000,10013:1500,10014:2000,554:100,6113:1000,6 moc_ruins,118,170,537:1000,643:10000,10013:1500,10014:2000,554:100,6113:1000,6114:1000,6115:1000 cave,76,39,712:1 job_gun,220,138,13210:250,13211:500,13212:750,13213:1000,13214:1200,7663:15,7664:30,7665:15 -alb2trea,87,65,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 ama_in01,24,30,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 ayo_in01,18,182,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 brasilis,252,257,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 -cmd_fild07,257,126,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 geffen_in,171,123,1092:3,1093:10,713:400 gonryun,147,84,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 hu_in01,252,368,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,1771:50,717:450 -yuno,218,97,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 yuno,226,107,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 -yuno_in01,25,34,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 -lhz_in02,31,145,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 lhz_in03,239,106,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 lou_in02,239,176,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 xmas_in,40,38,611:40,602:1000,601:250,23280:10,23288:1000,645:1200,656:2200,657:4500,501:10,502:50,503:180,504:1200,506:40,610:12000,713:400,717:450,1771:50 @@ -269,26 +264,4 @@ payon,157,96,11621:800,11622:1200,11623:1500,11624:7000 payon_in01,12,53,11621:800,11622:1200,11623:1500,11624:7000 payon_in02,85,38,11621:800,11622:1200,11623:1500,11624:7000 ma_in01,73,22,1207:2400,1216:19500,1107:2900,1122:24000,1116:2000,1154:22500,1407:3450,1457:27000,1354:15500,1519:23000 -ma_in01,22,23,611:40,1750:1,501:10,502:50,503:180,504:1200,645:1200,656:2200,601:250,602:1000,1065:50,2239:10000 -malaya,150,261,512:15,513:15,515:15,516:15,535:15 -ma_in01,67,13,2211:400,2401:400,2403:3500,2501:1000,2503:5000,2101:500,2103:14000,2305:1000,2321:8000,2332:7000,2328:5500,2627:20000 -prt_in,109,68,7940:100,12341:30000 -prt_in,175,137,2139:20000,2800:100000,2801:100000,2802:500000,2803:100000,2804:100000,2805:100000,2806:150000,2807:200000,18000:30,18001:50,18002:50,18003:50,18004:80,12392:200,12393:500,12394:1375,6145:12,6146:375,6147:125,6186:500,2808:250000 -s_atelier,17,110,6123:250,6120:150 -prontera,96,209,25187:1000,7663:15,7664:30,7665:15,7940:300,13200:1,13221:5,13222:5,13215:5,13216:5,13217:5,13218:5,13219:5,13220:5,13228:5,13229:5,13231:5,13232:5,13230:5 -prontera,92,209,13120:1200000,13122:1450000,13189:1350000,13195:1800000,13192:1250000,13193:1350000,13194:1950000,13197:1600000,13198:2200000,28200:2700000,28201:2800000 -prt_in,129,68,11621:800,11622:1200,11623:1500,11624:7000 -prt_in,117,79,2239:10000,2201:5000,2243:20000,2212:1000,2242:24000,2241:5000 -ra_in01,263,281,7940:100,12341:30000 -ra_in01,257,266,2139:20000,2800:100000,2801:100000,2802:500000,2803:100000,2804:100000,2805:100000,2806:150000,2807:200000,18000:30,18001:50,18002:50,18003:50,18004:80,12392:200,12393:500,12394:1375,6145:12,6146:375,6147:125,6186:500,2808:250000 -s_atelier,137,60,6123:250,6120:150 -prontera,156,212,12849:40,581:40,7482:375,580:150,577:200,7457:375,7454:525,7455:525,7453:525,7456:750,7452:750,579:700,12125:500,12126:1000,12127:2000,7472:750,7473:750,7474:750,7475:750,7476:750 -moc_ruins,115,123,12849:40,581:40,7482:375,580:150,577:200,7457:375,7454:525,7455:525,7453:525,7456:750,7452:750,579:700,12125:500,12126:1000,12127:2000 -geffen,196,111,12849:40,581:40,7482:375,580:150,577:200,7457:375,7454:525,7455:525,7453:525,7456:750,7452:750,579:700,12125:500,12126:1000,12127:2000 -alberta,167,135,12849:40,581:40,7482:375,580:150,577:200,7457:375,7454:525,7455:525,7453:525,7456:750,7452:750,579:700,12125:500,12126:1000,12127:2000 -payon,206,119,12849:40,581:40,7482:375,580:150,577:200,7457:375,7454:525,7455:525,7453:525,7456:750,7452:750,579:700,12125:500,12126:1000,12127:2000 -te_prt_gld,114,153,11557:200,11558:275 -dali,109,94,611:40,1750:1,1065:50 -#auction_03,145,47,611:40,501:10,502:50,503:180,506:40,504:1200,645:1200,656:2200,657:4500,601:250,602:1000,1065:50,911:120,910:30,717:450,1092:3,1093:10,713:400 -moro_vol,95,96,611:40,501:10,502:50,503:180,504:1200,645:1200,656:2200,601:250,602:1000,1065:50,1750:1 -verus04,144,239,611:40,504:1200,656:2200,601:250,602:1000,610:12000 +ma_in01,22,23,611:40,1750:1,501:10,502:50,503:180,504:1200,645:1200,656