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.
This commit is contained in:
dam 2026-04-30 00:02:38 +03:00
parent 0d2f472d96
commit 4d50efbddc
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
5 changed files with 287 additions and 25 deletions

View file

@ -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<Point>, val projectedLocation: Location)
private val intents = LinkedHashMap<Entity, Intent>()
@ -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<Point> {
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<Location> {
val tiles = ArrayList<Location>(entity.size() * entity.size())
for (x in 0 until entity.size()) {

View file

@ -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

View file

@ -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
}

View file

@ -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);

View file

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