Fixed melee chase timing for running PvP targets

Add a late combat movement pressure pass after world pulses so melee attackers can react to target run paths queued by live walk packets in the same tick. This prevents the attacker from lagging one tile too far behind when a PvP target starts running away.

Also add regressions for melee pressure against running targets, including the live WorldspaceWalk path with run enabled and dragon scimitars, and update the combat movement rewrite notes.
This commit is contained in:
dam 2026-04-29 21:05:34 +03:00
parent 4f0d44db38
commit aa5f25fed4
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
5 changed files with 192 additions and 9 deletions

View file

@ -3,6 +3,7 @@ package core.game.node.entity.combat
import core.game.node.entity.Entity
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.world.repository.Repository
import core.game.world.map.Location
import core.game.world.map.Point
import core.game.world.map.path.Path
@ -30,6 +31,16 @@ object CombatMovementIntents {
intents[attacker] = Intent(attacker, target)
}
@JvmStatic
fun requestActiveMeleePressure() {
for (attacker in Repository.players) {
requestActiveMeleePressure(attacker)
}
for (attacker in Repository.renderableNpcs) {
requestActiveMeleePressure(attacker)
}
}
@JvmStatic
fun resolve() {
if (intents.isEmpty()) {
@ -58,6 +69,29 @@ object CombatMovementIntents {
return intents.size
}
@JvmStatic
fun shouldMaintainMeleePressure(attacker: Entity, target: Entity): Boolean {
if (attacker.locks.isMovementLocked) {
return false
}
if (CombatMovementPlanner.movementStepsThisTick(target) == 0) {
return false
}
val predictedTargetLocation = CombatMovementPlanner.predictedTargetLocation(target)
return attacker.location !in CombatMovementPlanner.candidateAttackTiles(attacker, target, predictedTargetLocation)
}
private fun requestActiveMeleePressure(attacker: Entity) {
val pulse = attacker.properties.combatPulse
val target = pulse.getVictim() ?: return
if (!pulse.isAttacking || pulse.style != CombatStyle.MELEE) {
return
}
if (shouldMaintainMeleePressure(attacker, target)) {
request(attacker, target)
}
}
private fun resolve(intent: Intent, reservedTiles: MutableSet<Location>) {
val attacker = intent.attacker
val target = intent.target
@ -65,16 +99,17 @@ object CombatMovementIntents {
return
}
val forceRun = shouldForceRun(attacker, target)
val plan = CombatMovementPlanner.plan(attacker, target)
val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, plan.targetLocation)
for (candidate in candidates) {
val candidatePath = pathTo(attacker, candidate) ?: continue
val candidatePath = pathTo(attacker, candidate, forceRun) ?: continue
val projectedTiles = occupiedTiles(attacker, candidatePath.projectedLocation)
if (projectedTiles.any { it in reservedTiles }) {
continue
}
candidatePath.path.walk(attacker)
walkPath(attacker, candidatePath.path, forceRun)
attacker.face(target)
reservedTiles.addAll(projectedTiles)
return
@ -94,22 +129,22 @@ object CombatMovementIntents {
return attacker !is NPC || !attacker.isNeverWalks
}
private fun pathTo(attacker: Entity, destination: Location): CandidatePath? {
private fun pathTo(attacker: Entity, destination: Location, forceRun: Boolean): CandidatePath? {
if (attacker.location == destination) {
return null
}
val path = Pathfinder.find(attacker, destination, false, pathfinderFor(attacker))
val projected = projectedMovementLocation(attacker, path) ?: return null
val projected = projectedMovementLocation(attacker, path, forceRun) ?: return null
return CandidatePath(path, projected)
}
private fun projectedMovementLocation(attacker: Entity, path: Path): Location? {
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()) {
return null
}
val steps = if (attacker.walkingQueue.isRunningBoth && points.size > 1) {
val steps = if ((forceRun || attacker.walkingQueue.isRunningBoth) && points.size > 1) {
2
} else {
1
@ -118,6 +153,26 @@ object CombatMovementIntents {
return Location.create(point.x, point.y, attacker.location.z)
}
private fun walkPath(attacker: Entity, path: Path, forceRun: Boolean) {
if (attacker.locks.isMovementLocked) {
return
}
attacker.walkingQueue.reset(forceRun || attacker.walkingQueue.isRunning)
for (step in path.points) {
attacker.walkingQueue.addPath(step.x, step.y)
}
}
private fun shouldForceRun(attacker: Entity, target: Entity): Boolean {
if (attacker.walkingQueue.isRunningBoth || attacker.walkingQueue.isRunDisabled) {
return false
}
if (attacker is Player && attacker.settings.runEnergy < 1.0) {
return false
}
return CombatMovementPlanner.movementStepsThisTick(target) > 1
}
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

@ -205,6 +205,9 @@ class CombatPulse(
}
val type = canInteract()
if (type == InteractionType.STILL_INTERACT) {
if (shouldMaintainMeleePressure()) {
CombatMovementIntents.request(entity!!, victim!!)
}
return true
}
if (entity == null || victim == null || entity.locks.isMovementLocked) {
@ -214,6 +217,15 @@ class CombatPulse(
return type == InteractionType.MOVE_INTERACT
}
private fun shouldMaintainMeleePressure(): Boolean {
val attacker = entity ?: return false
val target = victim ?: return false
if (style != CombatStyle.MELEE || attacker.locks.isMovementLocked) {
return false
}
return CombatMovementIntents.shouldMaintainMeleePressure(attacker, target)
}
/**
* Sets the combat style.
*/

View file

@ -119,6 +119,7 @@ class MajorUpdateWorker {
GameWorld.Pulser.updateAll()
}
GameWorld.tickListeners.forEach { it.tick() }
CombatMovementIntents.requestActiveMeleePressure()
CombatMovementIntents.resolve()
sequence.start()

View file

@ -1,11 +1,18 @@
package content
import TestUtils
import core.api.EquipmentSlot
import core.game.node.entity.Entity
import core.game.node.entity.combat.CombatMovementIntents
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.map.Location
import core.game.world.map.RegionManager
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.assertNotEquals
import org.junit.jupiter.api.Assertions.assertTrue
@ -40,6 +47,66 @@ class CombatMovementTests {
}
}
@Test
fun meleeAttackerShouldQueueRunStepWhenCurrentTargetIsLeavingMeleeRange() {
TestUtils.getMockPlayer("combat_active_mirror_attacker").use { attacker ->
TestUtils.getMockPlayer("combat_active_mirror_victim").use { victim ->
val origin = arenaOrigin()
place(attacker, origin)
place(victim, origin.transform(1, 0, 0))
configureMelee(attacker)
configureMelee(victim)
enablePvp(attacker, victim)
CombatMovementIntents.clear()
attacker.attack(victim)
queueRun(victim, origin.transform(8, 0, 0))
core.game.world.GameWorld.Pulser.updateAll()
CombatMovementIntents.resolve()
attacker.walkingQueue.update()
victim.walkingQueue.update()
assertTrue(
meleeReach(attacker, victim),
"Attacker should run with a target leaving melee range. " +
"attacker=${attacker.location}, victim=${victim.location}"
)
}
}
}
@Test
fun meleeAttackerShouldFollowVictimRunQueuedByLiveWalkPacket() {
TestUtils.getMockPlayer("combat_packet_mirror_attacker").use { attacker ->
TestUtils.getMockPlayer("combat_packet_mirror_victim").use { victim ->
val origin = arenaOrigin()
place(attacker, origin)
place(victim, origin.transform(1, 0, 0))
configureMelee(attacker)
configureMelee(victim)
equipDragonScimitar(attacker)
equipDragonScimitar(victim)
enableRun(attacker)
enableRun(victim)
enablePvp(attacker, victim)
CombatMovementIntents.clear()
PacketProcessor.queue.clear()
TestUtils.advanceTicks(1, false)
CombatMovementIntents.clear()
attacker.attack(victim)
PacketProcessor.enqueue(Packet.WorldspaceWalk(victim, origin.x + 8, origin.y, true))
TestUtils.advanceTicks(1, false)
assertTrue(
meleeReach(attacker, victim),
"Attacker should mirror a live run-click on the tick it is queued. " +
"attacker=${attacker.location}, victim=${victim.location}"
)
}
}
}
@Test
fun mutualMeleeAttackersShouldApproachInsteadOfWaitingForTheOtherActor() {
TestUtils.getMockPlayer("combat_meet_a").use { first ->
@ -170,6 +237,15 @@ class CombatMovementTests {
entity.properties.combatPulse.updateStyle()
}
private fun equipDragonScimitar(player: Player) {
player.equipment.replace(Item(Items.DRAGON_SCIMITAR_4587), EquipmentSlot.WEAPON.ordinal)
}
private fun enableRun(player: Player) {
player.settings.runEnergy = 100.0
player.settings.setRunToggled(true)
}
private fun enablePvp(first: Entity, second: Entity) {
core.game.world.GameWorld.settings!!.wild_pvp_enabled = true
first.asPlayer().skullManager.isWilderness = true

View file

@ -94,10 +94,10 @@ Initial targets:
1. [x] Extract a `CombatReach` utility from the swing handlers.
2. [x] Add a `CombatMovementPlanner` that can choose target border tiles and predict
one or two target movement steps from `WalkingQueue`.
3. [ ] Add a combat movement intent phase that resolves step conflicts
3. [x] Add a combat movement intent phase that resolves step conflicts
deterministically.
4. [ ] Remove the private `MovementPulse` from `CombatPulse`.
5. [ ] Enable the pending tests one scenario at a time.
4. [x] Remove the private `MovementPulse` from `CombatPulse`.
5. [x] Enable the pending tests one scenario at a time.
## Progress Log
@ -114,3 +114,42 @@ Initial targets:
the closest valid border tile around the predicted occupied area. The planner
is not yet wired into `CombatPulse`; the next step is the deterministic combat
movement intent phase.
- 2026-04-29: Added `CombatMovementIntents`, an engine-side queue resolved from
`MajorUpdateWorker` after world tick listeners and before entity walking
queues update. Melee combat pulses now submit chase intents instead of using
the private generic `MovementPulse`; non-melee combat still uses the existing
path until the private movement helper is removed. Intent resolution orders
movers by entity index, paths to planner-selected border tiles, and reserves
each mover's projected occupied tiles for the current tick to keep first-step
conflicts deterministic.
- 2026-04-29: Removed the private generic `MovementPulse` from `CombatPulse`.
Combat pulses now submit combat movement intents directly whenever the swing
handler reports that the attacker cannot stand-still interact. Range and magic
still rely on their existing swing-distance checks to stop movement once they
are in range; the shared intent resolver owns the chase path.
- 2026-04-29: Enabled the first pending combat movement scenario:
`meleeAttackerShouldMirrorRunningVictimAndKeepAttackPressure`. The fixture now
uses an open wilderness arena and explicitly enables wilderness PvP for player
versus player movement tests so combat pulses do not stop at attackability
checks before movement can be exercised.
- 2026-04-29: Enabled the remaining pending combat movement scenarios:
mutual melee approach, player versus moving melee NPC chase, movement-locked
in-range melee, and large-target occupied-tile melee reach. The focused
`content.CombatMovementTests` suite now runs all five scenarios with no
disabled tests.
- 2026-04-29: Fixed the remaining melee chase gap against running targets. A
melee attacker now submits chase pressure even while currently in melee range
if the target's queued movement would leave that range, and combat intent
resolution force-runs the attacker's chase path when the target's next
movement tick is a run. Added a regression covering the active pulse/intent
handoff so a player attacking a running-away target stays adjacent after both
walking queues advance.
- 2026-04-29: Fixed the live PvP run-click ordering gap. The previous pressure
check only happened inside the attacker's `CombatPulse`, so a victim's normal
map-click `MovementPulse` could be queued later in the same pulser batch; the
attacker then saw no target path and stood still while the victim's run path
was applied before walking queues advanced. `MajorUpdateWorker` now asks
`CombatMovementIntents` to collect active melee pressure after all pulses and
tick listeners, then resolves intents before entity walking queues step. Added
a regression that uses the real `WorldspaceWalk` packet path with both players
run-enabled and wielding dragon scimitars.