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 75b8ca39e..e0e8e9b37 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -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) { 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 { 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/CombatPulse.kt b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt index b804f2147..5760ccbab 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt @@ -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. */ diff --git a/Server/src/main/core/worker/MajorUpdateWorker.kt b/Server/src/main/core/worker/MajorUpdateWorker.kt index e923ea4e4..1cfab244d 100644 --- a/Server/src/main/core/worker/MajorUpdateWorker.kt +++ b/Server/src/main/core/worker/MajorUpdateWorker.kt @@ -119,6 +119,7 @@ class MajorUpdateWorker { GameWorld.Pulser.updateAll() } GameWorld.tickListeners.forEach { it.tick() } + CombatMovementIntents.requestActiveMeleePressure() CombatMovementIntents.resolve() sequence.start() diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index 7063937c3..0045aa83b 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -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 diff --git a/docs/combat-movement-rewrite.md b/docs/combat-movement-rewrite.md index 10c7dbedf..47c4244e0 100644 --- a/docs/combat-movement-rewrite.md +++ b/docs/combat-movement-rewrite.md @@ -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.