From 7751971c15ff5f01b35bdd98355324e14acf6756 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 20:14:31 +0300 Subject: [PATCH] Fixed combat pathing around blocked attack tiles Filtered combat chase destinations so melee avoids interaction-blocked border tiles, and ranged/magic approach tiles require projectile line of sight before being selected. This fixes looping when attacking through the opened Lumbridge house door and when autocasting against Lumbridge river ducks from projectile-blocked spell range. --- .../entity/combat/CombatMovementIntents.kt | 56 +++++- .../entity/combat/CombatMovementPlanner.kt | 20 ++- .../kotlin/content/CombatMovementTests.kt | 162 ++++++++++++++++++ 3 files changed, 233 insertions(+), 5 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 3a42ffffe..a4c54fd1a 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -22,7 +22,7 @@ import java.util.LinkedHashMap * walking queues are ticked. */ object CombatMovementIntents { - private const val MAX_RANGED_APPROACH_CANDIDATES = 32 + private const val MAX_RANGED_APPROACH_CANDIDATES = 128 private data class Intent(val attacker: Entity, val target: Entity) private data class MovementDestination( @@ -170,13 +170,29 @@ object CombatMovementIntents { MovementDestination(it, pathfinder, allowPartialPath = false) } if (attacker is Player) { + val targetFallback = if (shouldAllowPartialTargetPath(attacker, target, targetLocation)) { + listOf(MovementDestination(target, pathfinder, allowPartialPath = true)) + } else { + emptyList() + } return playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder) + destinations + - MovementDestination(target, pathfinder, allowPartialPath = true) + targetFallback } return destinations } + private fun shouldAllowPartialTargetPath(attacker: Player, target: Entity, targetLocation: Location): Boolean { + val range = playerAttackRange(attacker) + if (range <= CombatReach.meleeDistance(attacker)) { + return true + } + if (CombatMovementPlanner.movementStepsThisTick(target) > 0) { + return true + } + return attacker.location.getDistance(closestOccupiedTile(target, targetLocation, attacker.location)) > range + } + private fun playerAttackRangeDestinations( attacker: Player, target: Entity, @@ -237,7 +253,7 @@ object CombatMovementIntents { } } } - return tiles.sortedWith( + return tiles.filter { hasProjectileLineOfSight(it, attacker.size(), target, targetLocation) }.sortedWith( compareBy { it.getDistance(attacker.location) } .thenBy { it.getDistance(closestOccupiedTile(target, targetLocation, it)) } .thenBy { it.x } @@ -245,6 +261,40 @@ object CombatMovementIntents { ).take(MAX_RANGED_APPROACH_CANDIDATES) } + private fun hasProjectileLineOfSight( + attackerLocation: Location, + attackerSize: Int, + target: Entity, + targetLocation: Location + ): Boolean { + for (sourceX in 0 until attackerSize) { + for (sourceY in 0 until attackerSize) { + val source = attackerLocation.transform(sourceX, sourceY, 0) + for (targetX in 0 until target.size()) { + for (targetY in 0 until target.size()) { + val destination = targetLocation.transform(targetX, targetY, 0) + val path = Pathfinder.PROJECTILE.find( + source, + 1, + destination, + 1, + 1, + 0, + 0, + 0, + false, + RegionManager::getClippingFlag + ) + if (path.isSuccessful) { + return true + } + } + } + } + } + return false + } + private fun closestOccupiedTile(entity: Entity, location: Location, from: Location): Location { return Location.create( from.x.coerceIn(location.x, location.x + entity.size() - 1), diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt index cca823a4f..bebb3a721 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt @@ -6,6 +6,7 @@ import core.game.node.entity.player.Player import core.game.world.map.Location import core.game.world.map.Point import core.game.world.map.RegionManager +import core.game.world.map.path.Pathfinder /** * Plans combat-specific chase targets without mutating walking queues. @@ -83,14 +84,29 @@ object CombatMovementPlanner { @JvmStatic fun candidateAttackTiles(attacker: Entity, target: Entity, targetLocation: Location): List { val candidates = borderTiles(target, targetLocation, attacker.size()) - val reachable = candidates.filter { RegionManager.isTeleportPermitted(it) } - return (reachable.ifEmpty { candidates }).sortedWith( + val walkable = candidates.filter { RegionManager.isTeleportPermitted(it) } + val attackable = walkable.filter { canInteractFrom(attacker, it, target, targetLocation) } + return (attackable.ifEmpty { walkable.ifEmpty { candidates } }).sortedWith( compareBy { it.getDistance(attacker.location) } .thenBy { it.x } .thenBy { it.y } ) } + private fun canInteractFrom(attacker: Entity, location: Location, target: Entity, targetLocation: Location): Boolean { + return Pathfinder.canInteract( + location.x, + location.y, + attacker.size(), + targetLocation.x, + targetLocation.y, + target.size(), + target.size(), + 0, + targetLocation.z + ) { z, x, y -> RegionManager.getClippingFlag(z, x, y) } + } + @JvmStatic fun borderTiles(target: Entity, targetLocation: Location, attackerSize: Int): List { val targetSize = target.size() diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index efa758594..6a90fceec 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -3,19 +3,27 @@ package content import MockSession import TestUtils import core.api.EquipmentSlot +import core.game.global.action.DoorActionHandler +import core.game.node.Node import core.game.node.entity.Entity import core.game.node.entity.combat.BattleState +import core.game.node.entity.combat.CombatSwingHandler import core.game.node.entity.combat.CombatMovementIntents import core.game.node.entity.combat.CombatMovementPlanner import core.game.node.entity.combat.equipment.WeaponInterface +import core.game.node.entity.combat.spell.CombatSpell +import core.game.node.entity.combat.spell.SpellType import core.game.node.entity.npc.NPC import core.game.node.entity.player.Player import core.game.node.entity.skill.Skills import core.game.node.item.Item +import core.game.node.scenery.SceneryBuilder +import core.game.system.config.DoorConfigLoader import core.game.world.GameWorld import core.game.world.map.Location import core.game.world.map.RegionManager import core.game.world.map.path.Pathfinder +import core.plugin.Plugin import core.net.packet.PacketProcessor import core.net.packet.`in`.Packet import org.rs09.consts.Items @@ -397,6 +405,74 @@ class CombatMovementTests { } } + @Test + fun magicAutocastShouldPathToProjectileClearLumbridgeRiverDuckTile() { + TestUtils.getMockPlayer("combat_magic_duck_attacker").use { player -> + val duckLocation = Location.create(3238, 3244, 0) + val start = walkableTileNear(duckLocation, minDistance = 11, maxRadius = 16) + place(player, start) + configureMagicAutocast(player) + enableRun(player) + + val duck = stationaryNpc(46, duckLocation) + try { + assertTrue(RegionManager.isTeleportPermitted(start), "Test start tile must be walkable.") + assertFalse(RegionManager.isTeleportPermitted(duckLocation), "Lumbridge river duck spawn should be on water.") + + player.attack(duck) + TestUtils.advanceTicks(12, false) + + assertTrue(player.properties.combatPulse.isAttacking) + assertTrue( + magicReach(player, duck), + "Autocast should stop on a projectile-clear tile instead of dancing within blocked range. " + + "player=${player.location}, duck=${duck.location}, distance=${player.location.getDistance(duck.location)}" + ) + assertFalse(receivedMessage(player, "I can't reach that!")) + } finally { + duck.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun magicAutocastShouldNotLoopWhenLumbridgeRiverDuckIsOnlyInBlockedRange() { + TestUtils.getMockPlayer("combat_magic_blocked_duck_attacker").use { player -> + val start = Location.create(3240, 3242, 0) + val duckLocation = Location.create(3235, 3259, 0) + place(player, start) + configureMagicAutocast(player) + enableRun(player) + + val duck = stationaryNpc(46, duckLocation) + try { + assertTrue(RegionManager.isTeleportPermitted(start), "Test start tile must be walkable.") + assertFalse(RegionManager.isTeleportPermitted(duckLocation), "Lumbridge river duck spawn should be on water.") + + player.attack(duck) + TestUtils.advanceTicks(18, false) + + assertFalse( + player.properties.combatPulse.isAttacking && !magicReach(player, duck), + "Autocast should not stay active while standing in projectile-blocked spell range. " + + "player=${player.location}, duck=${duck.location}, distance=${player.location.getDistance(duck.location)}, " + + "projectile=${CombatSwingHandler.isProjectileClipped(player, duck, false)}" + ) + if (!player.properties.combatPulse.isAttacking) { + assertTrue(receivedMessage(player, "I can't reach that!")) + assertTrue( + player.walkingQueue.queue.size <= 1, + "Stopping blocked autocast should not leave a chase path queued." + ) + } + } finally { + duck.clear() + CombatMovementIntents.clear() + } + } + } + @Test fun meleePlayerShouldPathTowardLumbridgeRiverDuckBeforeRejecting() { TestUtils.getMockPlayer("combat_melee_duck_attacker").use { player -> @@ -723,6 +799,55 @@ class CombatMovementTests { } } + @Test + fun meleePlayerShouldRouteAroundOpenedLumbridgeHouseDoorToReachNpc() { + TestUtils.getMockPlayer("combat_lumbridge_house_door_attacker").use { player -> + val start = Location.create(3230, 3236, 0) + val expectedAttackTile = Location.create(3231, 3237, 0) + val doorLocation = Location.create(3230, 3235, 0) + place(player, start) + configureMelee(player) + disableRun(player) + + val door = RegionManager.getObject(doorLocation.z, doorLocation.x, doorLocation.y, 36846) + ?: throw AssertionError("Expected Lumbridge house door 36846 at $doorLocation.") + assertEquals(1, door.rotation, "Expected the Lumbridge house door to face east.") + val doorConfig = DoorConfigLoader.forId(door.id) + ?: throw AssertionError("Expected door config for ${door.id}.") + val openedDoorLocation = door.location.transform(0, 1, 0) + DoorActionHandler.open(door, null, doorConfig.replaceId, -1, true, -1, doorConfig.isFence) + + val npc = NPC.create(100, Location.create(3231, 3236, 0)) + npc.init() + try { + configureMelee(npc) + player.attack(npc) + TestUtils.advanceTicks(6, false) + + assertEquals( + expectedAttackTile, + player.location, + "Player should route north around the opened southern door instead of bouncing south." + ) + assertTrue(player.properties.combatPulse.isAttacking) + assertTrue(meleeReach(player, npc)) + assertFalse(receivedMessage(player, "I can't reach that!")) + } finally { + npc.clear() + val openedDoor = RegionManager.getObject( + openedDoorLocation.z, + openedDoorLocation.x, + openedDoorLocation.y, + doorConfig.replaceId + ) + if (openedDoor != null) { + SceneryBuilder.replace(openedDoor, door) + } + CombatMovementIntents.clear() + } + } + } + private fun arenaOrigin(): Location { return Location.create(3200, 3600, 0) } @@ -763,6 +888,18 @@ class CombatMovementTests { player.properties.combatPulse.updateStyle() } + private fun configureMagicAutocast(player: Player) { + player.equipment.replace(Item(Items.STAFF_OF_AIR_1381), EquipmentSlot.WEAPON.ordinal) + player.skills.setStaticLevel(Skills.MAGIC, 99) + player.skills.setLevel(Skills.MAGIC, 99) + player.properties.autocastSpell = TestAutocastSpell + player.properties.attackStyle = WeaponInterface.AttackStyle( + WeaponInterface.STYLE_CAST, + WeaponInterface.BONUS_MAGIC + ) + player.properties.combatPulse.updateStyle() + } + private fun equipDragonScimitar(player: Player) { player.equipment.replace(Item(Items.DRAGON_SCIMITAR_4587), EquipmentSlot.WEAPON.ordinal) } @@ -817,6 +954,31 @@ class CombatMovementTests { return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 1.0 } + private fun magicReach(attacker: Entity, victim: Entity): Boolean { + return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 10.0 && + CombatSwingHandler.isProjectileClipped(attacker, victim, false) + } + + private object TestAutocastSpell : CombatSpell() { + init { + spellId = 1 + } + + override fun getMaximumImpact(entity: Entity, victim: Entity, state: BattleState): Int { + return 1 + } + + override fun visualize(entity: Entity, target: Node?) { + } + + override fun visualizeImpact(entity: Entity?, target: Entity?, state: BattleState?) { + } + + override fun newInstance(arg: SpellType?): Plugin { + return this + } + } + private fun blockMovementTiles(tiles: List) { for (tile in tiles) { RegionManager.addClippingFlag(tile.z, tile.x, tile.y, false, movementBlockFlag)