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/src/AI/Attack.pm b/src/AI/Attack.pm index 42b711fade..7bb0107941 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}; @@ -186,6 +216,8 @@ sub process { 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 +235,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 +246,15 @@ 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 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 +273,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; @@ -251,6 +291,8 @@ sub reset_approach_for_moved_target { } 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 +315,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 +329,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 +350,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 +385,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 +434,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 +450,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 +470,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 +492,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); @@ -459,6 +517,7 @@ sub main { 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; + # 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 +542,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}; @@ -539,7 +599,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"; @@ -598,6 +659,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 @@ -624,8 +687,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); @@ -650,8 +713,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 +728,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 +779,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"} && @@ -754,7 +816,7 @@ sub main { } } - # 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 +868,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; @@ -900,6 +963,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..0cdb11cebc 100644 --- a/src/AI/CoreLogic.pm +++ b/src/AI/CoreLogic.pm @@ -484,9 +484,10 @@ 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"; my $first = 1; @@ -549,6 +550,14 @@ sub processPortalRecording { debug "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; @@ -565,8 +574,7 @@ sub processPortalRecording { # 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"; @@ -574,69 +582,181 @@ sub processPortalRecording { $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("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 "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("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 "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("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("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 ##### sub processEscapeUnknownMaps { # escape from unknown maps. Happens when kore accidentally teleports onto an @@ -1843,7 +1963,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 +1973,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'}; @@ -3295,13 +3415,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; 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/Misc.pm b/src/Misc.pm index 51d82abe9b..b775f73e13 100644 --- a/src/Misc.pm +++ b/src/Misc.pm @@ -940,6 +940,56 @@ 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 = $cast->{targetID} || ($cast->{target} && $cast->{target}{ID}); + next unless defined $targetID && $targetID eq $target->{ID}; + + my $handle = $cast->{skill}->getHandle(); + next unless defined $handle; + + foreach my $skillName (@skills) { + return 1 if ($handle eq $skillName); + } + } + + 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}; + + my $cast = $member->{casting}; + my $handle = $cast->{skill}->getHandle(); + next unless defined $handle; + + foreach my $skillName (@skills) { + return 1 if ($handle eq $skillName); + } + } + + return 0; +} + ## # objectIsMovingTowards(object1, object2, [max_variance]) # @@ -4542,6 +4592,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 +4652,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 +4680,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 +4724,8 @@ sub getBestTarget { } my $dist = scalar @{$solution}; + + next if ($dist > $config{attackRouteMaxPathDistance}); my $priority = monsterPriority($monster->{name}, $monster->{nameID}); @@ -5611,6 +5690,10 @@ 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"}) { @@ -5893,6 +5976,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"}) { @@ -6065,6 +6150,12 @@ 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"}); + } if ($config{$prefix."_whenGround"}) { return 0 unless whenGroundStatus(calcPosition($monster), $config{$prefix."_whenGround"}); diff --git a/src/Network/Receive.pm b/src/Network/Receive.pm index 970a82261b..281ebb1316 100644 --- a/src/Network/Receive.pm +++ b/src/Network/Receive.pm @@ -3435,10 +3435,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 +3465,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}); } } @@ -4569,12 +4566,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}) { @@ -9838,12 +9843,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 +11833,7 @@ sub skill_cast { my $skill = new Skill(idn => $skillID); $source->{casting} = { skill => $skill, + targetID => $targetID, target => $target, x => $x, y => $y, diff --git a/src/Task/CalcMapRoute.pm b/src/Task/CalcMapRoute.pm index 50f9b79c06..d63285a1be 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; } @@ -414,14 +417,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 +534,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 +582,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 +756,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..765b0c2454 100644 --- a/src/Task/MapRoute.pm +++ b/src/Task/MapRoute.pm @@ -420,7 +420,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 +665,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 +674,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; @@ -781,7 +783,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); @@ -1009,6 +1012,30 @@ sub subtaskDone { sub mapChanged { my (undef, undef, $holder) = @_; my $self = $holder->[0]; + + if ($config{portalUpdatePosition} + && $self->{mapSolution} + && @{$self->{mapSolution}} + && $self->{mapSolution}[0]{portal} + && !$self->{mapSolution}[0]{steps} + && $self->_currentPortalSourceEntry('portals_lut')) { + my ($from, $to) = split(/=/, $self->{mapSolution}[0]{portal}, 2); + if (defined $from && defined $to) { + my ($dest_map, $dest_x, $dest_y) = split(/\s+/, $to, 3); + if (defined $dest_map && defined $dest_x && defined $dest_y) { + $ai_v{portalUpdatePosition_candidate} = { + 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, + }; + } + } + } + $self->{mapChanged} = 1; my $subtask = $self->getSubtask(); diff --git a/src/Task/Route.pm b/src/Task/Route.pm index aabc3b5234..3081b61c3b 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}; @@ -481,7 +481,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}) { diff --git a/src/test/CastConditionsTest.pm b/src/test/CastConditionsTest.pm new file mode 100644 index 0000000000..5df744fcb9 --- /dev/null +++ b/src/test/CastConditionsTest.pm @@ -0,0 +1,293 @@ +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 => 'AL_BLESSING'), + targetID => $char->{ID}, + target => $char, + }; + $Globals::playersList->add($caster); + + local %Globals::config = ( + selftest_manualAI => 2, + selftest_notWhileBeingCasted => 'AL_BLESSING', + ); + local %Misc::config = %Globals::config; + + ok(!Misc::checkSelfCondition('selftest'), 'self condition fails while blessing is being cast on self'); + + $Globals::config{selftest_notWhileBeingCasted} = 'Blessing'; + $Misc::config{selftest_notWhileBeingCasted} = 'Blessing'; + ok(Misc::checkSelfCondition('selftest'), 'self condition ignores skill names and only matches handles'); + + $Globals::config{selftest_notWhileBeingCasted} = 'HP_ASSUMPTIO'; + $Misc::config{selftest_notWhileBeingCasted} = 'HP_ASSUMPTIO'; + 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 => 'AL_BLESSING'), + targetID => $char->{ID}, + target => $char, + }; + $Globals::playersList->add($caster); + + local %Globals::config = ( + selftest_manualAI => 2, + selftest_whileBeingCasted => 'AL_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} = 'Blessing'; + $Misc::config{selftest_whileBeingCasted} = 'Blessing'; + ok(!Misc::checkSelfCondition('selftest'), 'self condition rejects skill names and only matches handles'); + + $Globals::config{selftest_whileBeingCasted} = 'HP_ASSUMPTIO'; + $Misc::config{selftest_whileBeingCasted} = 'HP_ASSUMPTIO'; + 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} = 'AL_BLESSING'; + $Misc::config{playertest_target_notWhileBeingCasted} = 'AL_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} = 'AL_BLESSING'; + $Misc::config{playertest_target_whileBeingCasted} = 'AL_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} = 'AL_BLESSING'; + $Misc::config{monstertest_target_notWhileBeingCasted} = 'AL_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} = 'AL_BLESSING'; + $Misc::config{monstertest_target_whileBeingCasted} = 'AL_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/unittests.pl b/src/test/unittests.pl index f5e9e194eb..b5388b8938 100755 --- a/src/test/unittests.pl +++ b/src/test/unittests.pl @@ -17,6 +17,7 @@ SetTest SkillTest InventoryListTest ItemsTest HandConditionsTest + CastConditionsTest ShopTest TaskManagerTest TaskWithSubtaskTest TaskChainedTest TaskTalkNPCTest