mirror of
https://gitlab.com/2009scape/2009scape.git
synced 2026-08-28 05:45:10 -06:00
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:
parent
4f0d44db38
commit
aa5f25fed4
5 changed files with 192 additions and 9 deletions
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ class MajorUpdateWorker {
|
|||
GameWorld.Pulser.updateAll()
|
||||
}
|
||||
GameWorld.tickListeners.forEach { it.tick() }
|
||||
CombatMovementIntents.requestActiveMeleePressure()
|
||||
CombatMovementIntents.resolve()
|
||||
|
||||
sequence.start()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue