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 }