From 4d50efbddc7dfd06f69097770a344b1dd4245c85 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 00:02:38 +0300 Subject: [PATCH] Fix combat movement reachability and stale target cleanup Reject partial pathfinder results for combat approach tiles so unreachable targets stop combat and show "I can't reach that!" instead of endlessly queueing chase movement. Also clear stale combat state on teleport/cross-map targets, keep combat movement queues to immediate steps, and prevent NPC retaliation from forcing run movement. --- .../entity/combat/CombatMovementIntents.kt | 64 ++++-- .../entity/combat/CombatMovementPlanner.kt | 10 + .../game/node/entity/combat/CombatPulse.kt | 21 +- .../game/node/entity/impl/WalkingQueue.java | 1 + .../kotlin/content/CombatMovementTests.kt | 216 ++++++++++++++++++ 5 files changed, 287 insertions(+), 25 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 e0e8e9b37..5d71012d8 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -16,7 +16,7 @@ import java.util.LinkedHashMap */ object CombatMovementIntents { private data class Intent(val attacker: Entity, val target: Entity) - private data class CandidatePath(val path: Path, val projectedLocation: Location) + private data class CandidatePath(val steps: List, val projectedLocation: Location) private val intents = LinkedHashMap() @@ -102,18 +102,23 @@ object CombatMovementIntents { val forceRun = shouldForceRun(attacker, target) val plan = CombatMovementPlanner.plan(attacker, target) val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, plan.targetLocation) + var blockedByReservation = false for (candidate in candidates) { val candidatePath = pathTo(attacker, candidate, forceRun) ?: continue val projectedTiles = occupiedTiles(attacker, candidatePath.projectedLocation) if (projectedTiles.any { it in reservedTiles }) { + blockedByReservation = true continue } - walkPath(attacker, candidatePath.path, forceRun) + walkPath(attacker, candidatePath, forceRun) attacker.face(target) reservedTiles.addAll(projectedTiles) return } + if (!blockedByReservation) { + stopUnreachableCombat(attacker) + } } private fun canResolve(attacker: Entity, target: Entity): Boolean { @@ -126,6 +131,10 @@ object CombatMovementIntents { if (attacker.properties.combatPulse.getVictim() !== target || !attacker.properties.combatPulse.isAttacking) { return false } + if (CombatMovementPlanner.exceedsCombatChaseDistance(attacker, target)) { + attacker.properties.combatPulse.stop() + return false + } return attacker !is NPC || !attacker.isNeverWalks } @@ -135,44 +144,67 @@ object CombatMovementIntents { } val path = Pathfinder.find(attacker, destination, false, pathfinderFor(attacker)) - val projected = projectedMovementLocation(attacker, path, forceRun) ?: return null - return CandidatePath(path, projected) - } - - private fun projectedMovementLocation(attacker: Entity, path: Path, forceRun: Boolean): Location? { - val points = path.points.filter { it.x != attacker.location.x || it.y != attacker.location.y } - if (points.isEmpty()) { + if (!path.reaches(destination)) { return null } - val steps = if ((forceRun || attacker.walkingQueue.isRunningBoth) && points.size > 1) { + val steps = immediateMovementSteps(attacker, path, forceRun) + val projected = steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) } ?: return null + return CandidatePath(steps, projected) + } + + private fun Path.reaches(destination: Location): Boolean { + if (!isSuccessful || isMoveNear) { + return false + } + val terminal = points.lastOrNull() ?: return false + return terminal.x == destination.x && terminal.y == destination.y + } + + private fun immediateMovementSteps(attacker: Entity, path: Path, forceRun: Boolean): List { + val points = path.points.filter { it.x != attacker.location.x || it.y != attacker.location.y } + if (points.isEmpty()) { + return emptyList() + } + val steps = if (attacker is Player && (forceRun || attacker.walkingQueue.isRunningBoth) && points.size > 1) { 2 } else { 1 } - val point: Point = points.take(steps).last() - return Location.create(point.x, point.y, attacker.location.z) + return points.take(steps) } - private fun walkPath(attacker: Entity, path: Path, forceRun: Boolean) { + private fun walkPath(attacker: Entity, path: CandidatePath, forceRun: Boolean) { if (attacker.locks.isMovementLocked) { return } - attacker.walkingQueue.reset(forceRun || attacker.walkingQueue.isRunning) - for (step in path.points) { + val run = attacker is Player && (forceRun || attacker.walkingQueue.isRunning) + attacker.walkingQueue.reset(run) + for (step in path.steps) { attacker.walkingQueue.addPath(step.x, step.y) } } private fun shouldForceRun(attacker: Entity, target: Entity): Boolean { + if (attacker !is Player) { + return false + } if (attacker.walkingQueue.isRunningBoth || attacker.walkingQueue.isRunDisabled) { return false } - if (attacker is Player && attacker.settings.runEnergy < 1.0) { + if (attacker.settings.runEnergy < 1.0) { return false } return CombatMovementPlanner.movementStepsThisTick(target) > 1 } + private fun stopUnreachableCombat(attacker: Entity) { + attacker.properties.combatPulse.stop() + attacker.walkingQueue.reset() + if (attacker is Player) { + attacker.packetDispatch.sendMessage("I can't reach that!") + } + } + private fun occupiedTiles(entity: Entity, location: Location): List { val tiles = ArrayList(entity.size() * entity.size()) for (x in 0 until entity.size()) { 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 d19c5e293..80a3dcd2c 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt @@ -1,5 +1,6 @@ package core.game.node.entity.combat +import core.ServerConstants import core.game.node.entity.Entity import core.game.world.map.Location import core.game.world.map.Point @@ -23,6 +24,15 @@ object CombatMovementPlanner { ) } + @JvmStatic + fun exceedsCombatChaseDistance(attacker: Entity, target: Entity): Boolean { + if (attacker.location.z != target.location.z) { + return true + } + val targetTile = target.getClosestOccupiedTile(attacker.location) + return attacker.location.getDistance(targetTile) > ServerConstants.MAX_PATHFIND_DISTANCE * 2.0 + } + @JvmStatic fun predictedTargetLocation(target: Entity): Location { return predictTargetLocations(target).lastOrNull() ?: target.location diff --git a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt index 5760ccbab..477b557b0 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt @@ -188,32 +188,35 @@ class CombatPulse( * @return `True` if so. */ private fun interactable(): Boolean { - if (victim == null) { - return false - } - if (entity is NPC && victim is Player && entity.isHidden(victim as Player?)) { + val attacker = entity ?: return false + val target = victim ?: return false + if (attacker is NPC && target is Player && attacker.isHidden(target)) { stop() return false } - if (victim is NPC && entity is Player && (victim as NPC).isHidden(entity as Player?)) { + if (target is NPC && attacker is Player && target.isHidden(attacker)) { stop() return false } - if (entity is NPC && !entity.asNpc().canStartCombat(victim)) { + if (attacker is NPC && !attacker.asNpc().canStartCombat(target)) { + stop() + return false + } + if (CombatMovementPlanner.exceedsCombatChaseDistance(attacker, target)) { stop() return false } val type = canInteract() if (type == InteractionType.STILL_INTERACT) { if (shouldMaintainMeleePressure()) { - CombatMovementIntents.request(entity!!, victim!!) + CombatMovementIntents.request(attacker, target) } return true } - if (entity == null || victim == null || entity.locks.isMovementLocked) { + if (attacker.locks.isMovementLocked) { return false } - CombatMovementIntents.request(entity, victim!!) + CombatMovementIntents.request(attacker, target) return type == InteractionType.MOVE_INTERACT } diff --git a/Server/src/main/core/game/node/entity/impl/WalkingQueue.java b/Server/src/main/core/game/node/entity/impl/WalkingQueue.java index fbd9d2e36..1cc1a32d3 100644 --- a/Server/src/main/core/game/node/entity/impl/WalkingQueue.java +++ b/Server/src/main/core/game/node/entity/impl/WalkingQueue.java @@ -236,6 +236,7 @@ public final class WalkingQueue { */ public boolean updateTeleport() { if (entity.getProperties().getTeleportLocation() != null) { + entity.getProperties().getCombatPulse().stop(); reset(false); entity.setLocation(entity.getProperties().getTeleportLocation()); entity.getProperties().setTeleportLocation(null); diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index 0045aa83b..9b2190c09 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -1,19 +1,25 @@ package content +import MockSession import TestUtils import core.api.EquipmentSlot import core.game.node.entity.Entity +import core.game.node.entity.combat.BattleState 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.npc.NPC import core.game.node.entity.player.Player import core.game.node.item.Item +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.net.packet.PacketProcessor import core.net.packet.`in`.Packet import org.rs09.consts.Items import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -214,6 +220,182 @@ class CombatMovementTests { } } + @Test + fun staleRangedCombatTargetAcrossMapShouldStopBeforePathfinding() { + TestUtils.getMockPlayer("combat_stale_seercull_attacker").use { player -> + val seers = Location.create(2726, 3485, 0) + val lumbridge = Location.create(3216, 3209, 0) + place(player, seers) + configureRanged(player) + + val npc = NPC.create(100, lumbridge) + npc.init() + try { + player.attack(npc) + TestUtils.advanceTicks(1, false) + + assertFalse( + player.properties.combatPulse.isAttacking, + "Cross-map combat targets should be discarded before combat movement pathfinding." + ) + assertEquals(seers, player.location) + assertTrue( + player.walkingQueue.queue.size <= 1, + "Stopping stale combat should not queue a path toward the old target." + ) + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun teleportShouldClearActiveCombatTarget() { + TestUtils.getMockPlayer("combat_teleporting_attacker").use { player -> + val lumbridge = Location.create(3216, 3209, 0) + val seers = Location.create(2726, 3485, 0) + place(player, lumbridge) + configureRanged(player) + + val npc = NPC.create(100, lumbridge.transform(2, 0, 0)) + npc.init() + try { + player.attack(npc) + assertTrue(player.properties.combatPulse.isAttacking) + + player.properties.teleportLocation = seers + player.walkingQueue.update() + + assertEquals(seers, player.location) + assertFalse( + player.properties.combatPulse.isAttacking, + "Teleporting should discard the previous combat target." + ) + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun unreachableCombatTargetShouldStopAndTellPlayer() { + TestUtils.getMockPlayer("combat_unreachable_target").use { player -> + val origin = arenaOrigin() + place(player, origin) + configureMelee(player) + + val npc = NPC.create(100, origin.transform(4, 0, 0)) + npc.init() + val blockedTiles = CombatMovementPlanner.borderTiles(npc, npc.location, player.size()) + try { + blockMovementTiles(blockedTiles) + + player.attack(npc) + TestUtils.advanceTicks(1, false) + + assertFalse( + player.properties.combatPulse.isAttacking, + "Unreachable combat targets should stop the combat pulse instead of retrying pathfinding." + ) + assertEquals(origin, player.location) + assertTrue( + player.walkingQueue.queue.size <= 1, + "Stopping unreachable combat should not keep a movement path queued." + ) + assertTrue(receivedMessage(player, "I can't reach that!")) + } finally { + unblockMovementTiles(blockedTiles) + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun nearbyMeleeNpcShouldRetaliateAndPathWithoutDisconnectingPlayers() { + TestUtils.getMockPlayer("combat_retaliation_attacker").use { player -> + val origin = Location.create(3216, 3209, 0) + place(player, origin) + configureRanged(player) + + val npc = NPC.create(100, origin.transform(8, 0, 0)) + npc.init() + try { + val damage = BattleState(player, npc) + damage.estimatedHit = 1 + + npc.onImpact(player, damage) + TestUtils.advanceTicks(2, false) + + assertTrue(npc.properties.combatPulse.isAttacking) + assertFalse((player.session as MockSession).disconnected) + assertTrue( + npc.location.getDistance(player.location) < 8.0, + "Retaliating NPC should step toward a nearby ranged attacker." + ) + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun combatMovementShouldOnlyQueueImmediateMovementSteps() { + TestUtils.getMockPlayer("combat_short_queue_attacker").use { player -> + val origin = Location.create(3216, 3209, 0) + place(player, origin) + configureMelee(player) + enableRun(player) + + val npc = NPC.create(100, origin.transform(8, 0, 0)) + npc.init() + try { + player.attack(npc) + GameWorld.Pulser.updateAll() + CombatMovementIntents.resolve() + + assertTrue( + player.walkingQueue.queue.size in 2..3, + "Combat movement should only queue the immediate movement step(s), not the full chase path." + ) + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun retaliatingNpcShouldNotForceRunTowardRunningPlayer() { + TestUtils.getMockPlayer("combat_npc_retaliation_target").use { player -> + val origin = arenaOrigin() + place(player, origin) + enableRun(player) + queueRun(player, origin.transform(12, 0, 0)) + + val npc = NPC.create(100, origin.transform(8, 0, 0)) + npc.init() + try { + val damage = BattleState(player, npc) + damage.estimatedHit = 1 + + npc.onImpact(player, damage) + GameWorld.Pulser.updateAll() + CombatMovementIntents.resolve() + npc.walkingQueue.update() + + assertNotEquals(-1, npc.walkingQueue.walkDir) + assertEquals(-1, npc.walkingQueue.runDir, "NPC combat retaliation should walk, not force-run.") + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + private fun arenaOrigin(): Location { return Location.create(3200, 3600, 0) } @@ -237,6 +419,16 @@ class CombatMovementTests { entity.properties.combatPulse.updateStyle() } + private fun configureRanged(player: Player) { + player.equipment.replace(Item(6724), EquipmentSlot.WEAPON.ordinal) + player.equipment.replace(Item(Items.BRONZE_ARROW_882, 100), EquipmentSlot.AMMO.ordinal) + player.properties.attackStyle = WeaponInterface.AttackStyle( + WeaponInterface.STYLE_RANGE_ACCURATE, + WeaponInterface.BONUS_RANGE + ) + player.properties.combatPulse.updateStyle() + } + private fun equipDragonScimitar(player: Player) { player.equipment.replace(Item(Items.DRAGON_SCIMITAR_4587), EquipmentSlot.WEAPON.ordinal) } @@ -257,4 +449,28 @@ class CombatMovementTests { private fun meleeReach(attacker: Entity, victim: Entity): Boolean { return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 1.0 } + + private fun blockMovementTiles(tiles: List) { + for (tile in tiles) { + RegionManager.addClippingFlag(tile.z, tile.x, tile.y, false, movementBlockFlag) + } + } + + private fun unblockMovementTiles(tiles: List) { + for (tile in tiles) { + RegionManager.removeClippingFlag(tile.z, tile.x, tile.y, false, movementBlockFlag) + } + } + + private fun receivedMessage(player: Player, message: String): Boolean { + return (player.session as MockSession).receivedPackets.any { packet -> + val end = packet.payload.indexOf(0.toByte()) + end == message.length && String(packet.payload, 0, end, Charsets.UTF_8) == message + } + } + + private val movementBlockFlag = Pathfinder.PREVENT_NORTH or + Pathfinder.PREVENT_EAST or + Pathfinder.PREVENT_SOUTH or + Pathfinder.PREVENT_WEST }