From dd56c211de05545dfc83b36e25e447cf1aa552a0 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Fri, 1 May 2026 15:18:39 +0300 Subject: [PATCH] Fixed combat pathing around unreachable targets --- .../entity/combat/CombatMovementIntents.kt | 178 ++++++++++++++++-- .../kotlin/content/CombatMovementTests.kt | 8 + 2 files changed, 171 insertions(+), 15 deletions(-) diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt index 82ab74e25..3d89849b0 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -24,6 +24,7 @@ import java.util.LinkedHashMap */ object CombatMovementIntents { private const val MAX_RANGED_APPROACH_CANDIDATES = 128 + private const val MAX_PLAYER_COMBAT_PATH_DETOUR = 6.0 private data class Intent(val attacker: Entity, val target: Entity) private data class MovementDestination( @@ -127,6 +128,7 @@ object CombatMovementIntents { return } + val targetLocation = targetLocationFor(attacker, target) val projectedTargetLocation = projectedLocations[target] if (projectedTargetLocation != null && canAttackFrom(attacker, target, attacker.location, projectedTargetLocation)) { attacker.walkingQueue.reset() @@ -137,7 +139,8 @@ object CombatMovementIntents { } val candidates = movementDestinationsFor(attacker, target) - if (candidates.any { it.location == attacker.location }) { + val standingOnCandidate = candidates.any { it.location == attacker.location } + if (standingOnCandidate && canAttackFrom(attacker, target, attacker.location, targetLocation)) { attacker.walkingQueue.reset() attacker.face(target) projectedLocations[attacker] = attacker.location @@ -145,7 +148,7 @@ object CombatMovementIntents { return } val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull() - if (projectedAttackerLocation != null && candidates.any { it.location == projectedAttackerLocation }) { + if (projectedAttackerLocation != null && canAttackFrom(attacker, target, projectedAttackerLocation, targetLocation)) { attacker.face(target) projectedLocations[attacker] = projectedAttackerLocation reservedTiles.addAll(occupiedTiles(attacker, projectedAttackerLocation)) @@ -167,7 +170,7 @@ object CombatMovementIntents { reservedTiles.addAll(projectedTiles) return } - if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target)) { + if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target, standingOnCandidate)) { stopUnreachableCombat(attacker) } } @@ -197,7 +200,13 @@ object CombatMovementIntents { if (attackerLocation.z != targetLocation.z) { return false } - return attackerLocation in CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation) + if (occupiedTilesOverlap(attacker, attackerLocation, target, targetLocation)) { + return false + } + return when (attacker.properties.combatPulse.style) { + CombatStyle.RANGE, CombatStyle.MAGIC -> canAttackFromRange(attacker, target, attackerLocation, targetLocation) + else -> canAttackFromMelee(attacker, target, attackerLocation, targetLocation) + } } private fun movementDestinationsFor(attacker: Entity, target: Entity): List { @@ -225,9 +234,8 @@ object CombatMovementIntents { } else { emptyList() } - return playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder) + - destinations + - targetFallback + val rangedDestinations = playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder) + return rangedDestinations + destinations + targetFallback } return destinations } @@ -311,11 +319,118 @@ object CombatMovementIntents { ).take(MAX_RANGED_APPROACH_CANDIDATES) } + private fun canAttackFromMelee( + attacker: Entity, + target: Entity, + attackerLocation: Location, + targetLocation: Location + ): Boolean { + val distance = CombatReach.meleeDistance(attacker) + if (distance == 1 && !isAdjacentToTarget(attacker, attackerLocation, target, targetLocation)) { + return false + } + if (distance > 1 && + attackerLocation.getDistance(closestOccupiedTile(target, targetLocation, attackerLocation)) > distance + ) { + return false + } + return hasProjectileLineOfSight( + attackerLocation, + attacker.size(), + target, + targetLocation, + checkClose = !CombatReach.isUsingHalberd(attacker) + ) + } + + private fun canAttackFromRange( + attacker: Entity, + target: Entity, + attackerLocation: Location, + targetLocation: Location + ): Boolean { + val range = if (attacker is Player) { + playerAttackRange(attacker) + } else { + CombatReach.combatDistance( + attacker, + target, + if (attacker.properties.combatPulse.style == CombatStyle.MAGIC) 10 else 7 + ) + } + return attackerLocation.getDistance(closestOccupiedTile(target, targetLocation, attackerLocation)) <= range && + hasProjectileLineOfSight(attackerLocation, attacker.size(), target, targetLocation) + } + + private fun isAdjacentToTarget( + attacker: Entity, + attackerLocation: Location, + target: Entity, + targetLocation: Location + ): Boolean { + for (i in 0 until attacker.size()) { + if (Pathfinder.isStandingIn( + attackerLocation.x - 1, + attackerLocation.y + i, + 1, + 1, + targetLocation.x, + targetLocation.y, + target.size(), + target.size() + ) + ) { + return true + } + if (Pathfinder.isStandingIn( + attackerLocation.x + attacker.size(), + attackerLocation.y + i, + 1, + 1, + targetLocation.x, + targetLocation.y, + target.size(), + target.size() + ) + ) { + return true + } + if (Pathfinder.isStandingIn( + attackerLocation.x + i, + attackerLocation.y - 1, + 1, + 1, + targetLocation.x, + targetLocation.y, + target.size(), + target.size() + ) + ) { + return true + } + if (Pathfinder.isStandingIn( + attackerLocation.x + i, + attackerLocation.y + attacker.size(), + 1, + 1, + targetLocation.x, + targetLocation.y, + target.size(), + target.size() + ) + ) { + return true + } + } + return false + } + private fun hasProjectileLineOfSight( attackerLocation: Location, attackerSize: Int, target: Entity, - targetLocation: Location + targetLocation: Location, + checkClose: Boolean = false ): Boolean { for (sourceX in 0 until attackerSize) { for (sourceY in 0 until attackerSize) { @@ -335,7 +450,7 @@ object CombatMovementIntents { false, RegionManager::getClippingFlag ) - if (path.isSuccessful) { + if (path.isSuccessful && (!checkClose || path.points.size <= 1)) { return true } } @@ -416,11 +531,31 @@ object CombatMovementIntents { if (!path.reaches(destination.location) && (!destination.allowPartialPath || path.points.isEmpty())) { return null } + if (attacker is Player && !destination.allowPartialPath && isExcessiveCombatDetour(attacker, destination, path)) { + return null + } val steps = immediateMovementSteps(attacker, path) + if (attacker is Player && destination.allowPartialPath && !partialPathMovesCloser(attacker, destination, steps)) { + return null + } + if (attacker is Player && steps.any { !RegionManager.isTeleportPermitted(Location.create(it.x, it.y, attacker.location.z)) }) { + return null + } val projected = steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) } ?: return null return CandidatePath(steps, projected) } + private fun isExcessiveCombatDetour(attacker: Player, destination: MovementDestination, path: Path): Boolean { + val pathLength = (path.points.size - 1).coerceAtLeast(0) + val directDistance = attacker.location.getDistance(destination.location) + return pathLength > directDistance + MAX_PLAYER_COMBAT_PATH_DETOUR + } + + private fun partialPathMovesCloser(attacker: Player, destination: MovementDestination, steps: List): Boolean { + val projected = steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) } ?: return false + return projected.getDistance(destination.location) < attacker.location.getDistance(destination.location) + } + private fun Path.reaches(destination: Location): Boolean { if (!isSuccessful || isMoveNear) { return false @@ -467,8 +602,12 @@ object CombatMovementIntents { return attacker is Player && attacker.walkingQueue.isRunningBoth && attacker.settings.runEnergy >= 1.0 } - private fun shouldStopUnreachableCombat(attacker: Entity, target: Entity): Boolean { - return attacker is Player && CombatMovementPlanner.movementStepsThisTick(target) == 0 + private fun shouldStopUnreachableCombat( + attacker: Entity, + target: Entity, + exhaustedLocalApproach: Boolean = false + ): Boolean { + return attacker is Player && (exhaustedLocalApproach || CombatMovementPlanner.movementStepsThisTick(target) == 0) } @JvmStatic @@ -491,10 +630,19 @@ object CombatMovementIntents { } private fun occupiedTilesOverlap(first: Entity, second: Entity): Boolean { - return first.location.x < second.location.x + second.size() && - first.location.x + first.size() > second.location.x && - first.location.y < second.location.y + second.size() && - first.location.y + first.size() > second.location.y + return occupiedTilesOverlap(first, first.location, second, second.location) + } + + private fun occupiedTilesOverlap( + first: Entity, + firstLocation: Location, + second: Entity, + secondLocation: Location + ): Boolean { + return firstLocation.x < secondLocation.x + second.size() && + firstLocation.x + first.size() > secondLocation.x && + firstLocation.y < secondLocation.y + second.size() && + firstLocation.y + first.size() > secondLocation.y } private fun pathfinderFor(attacker: Entity): Pathfinder { diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index 239a7ecb0..ed28d3b1d 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -670,6 +670,10 @@ class CombatMovementTests { "Stopping blocked autocast should not leave a chase path queued." ) } + assertTrue( + player.location.getDistance(duck.location) <= start.getDistance(duck.location), + "Blocked autocast should not route away around the river before resolving combat." + ) } finally { duck.clear() CombatMovementIntents.clear() @@ -716,6 +720,10 @@ class CombatMovementTests { "location=${player.location}, distance=${player.location.getDistance(duck.location)}, " + "isAttacking=${player.properties.combatPulse.isAttacking}" ) + assertTrue( + player.location.getDistance(duck.location) <= startDistance, + "Melee combat should not route away around the river before rejecting the duck." + ) } finally { duck.clear() CombatMovementIntents.clear()