mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-28 05:45:10 -06:00
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:
parent
0d2f472d96
commit
4d50efbddc
5 changed files with 287 additions and 25 deletions
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue