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.
This commit is contained in:
dam 2026-04-30 20:14:31 +03:00
parent b7d0aa86f7
commit 7751971c15
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
3 changed files with 233 additions and 5 deletions

View file

@ -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<Location> { 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),

View file

@ -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<Location> {
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<Location> { 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<Location> {
val targetSize = target.size()

View file

@ -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<SpellType> {
return this
}
}
private fun blockMovementTiles(tiles: List<Location>) {
for (tile in tiles) {
RegionManager.addClippingFlag(tile.z, tile.x, tile.y, false, movementBlockFlag)