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)