From 64b6d858a037329320785dd0fe0538de70403043 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 14:27:09 +0300 Subject: [PATCH] Forced run fix --- .../entity/combat/CombatMovementIntents.kt | 52 ++++++------- .../entity/combat/CombatMovementPlanner.kt | 12 ++- .../kotlin/content/CombatMovementTests.kt | 77 ++++++++++++++++++- Server/src/test/kotlin/content/CombatTests.kt | 18 +++++ 4 files changed, 129 insertions(+), 30 deletions(-) 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 5d71012d8..55648ee59 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -99,24 +99,23 @@ object CombatMovementIntents { return } - val forceRun = shouldForceRun(attacker, target) - val plan = CombatMovementPlanner.plan(attacker, target) - val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, plan.targetLocation) + val targetLocation = targetLocationFor(attacker, target) + val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation) var blockedByReservation = false for (candidate in candidates) { - val candidatePath = pathTo(attacker, candidate, forceRun) ?: continue + val candidatePath = pathTo(attacker, candidate) ?: continue val projectedTiles = occupiedTiles(attacker, candidatePath.projectedLocation) if (projectedTiles.any { it in reservedTiles }) { blockedByReservation = true continue } - walkPath(attacker, candidatePath, forceRun) + walkPath(attacker, candidatePath) attacker.face(target) reservedTiles.addAll(projectedTiles) return } - if (!blockedByReservation) { + if (!blockedByReservation && CombatMovementPlanner.movementStepsThisTick(target) == 0) { stopUnreachableCombat(attacker) } } @@ -138,7 +137,7 @@ object CombatMovementIntents { return attacker !is NPC || !attacker.isNeverWalks } - private fun pathTo(attacker: Entity, destination: Location, forceRun: Boolean): CandidatePath? { + private fun pathTo(attacker: Entity, destination: Location): CandidatePath? { if (attacker.location == destination) { return null } @@ -147,7 +146,7 @@ object CombatMovementIntents { if (!path.reaches(destination)) { return null } - val steps = immediateMovementSteps(attacker, path, forceRun) + val steps = immediateMovementSteps(attacker, path) val projected = steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) } ?: return null return CandidatePath(steps, projected) } @@ -160,41 +159,42 @@ object CombatMovementIntents { return terminal.x == destination.x && terminal.y == destination.y } - private fun immediateMovementSteps(attacker: Entity, path: Path, forceRun: Boolean): List { + private fun immediateMovementSteps(attacker: Entity, path: Path): List { 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 steps = movementStepsFor(attacker).coerceAtMost(points.size) return points.take(steps) } - private fun walkPath(attacker: Entity, path: CandidatePath, forceRun: Boolean) { + private fun walkPath(attacker: Entity, path: CandidatePath) { if (attacker.locks.isMovementLocked) { return } - val run = attacker is Player && (forceRun || attacker.walkingQueue.isRunning) + val run = attacker is Player && attacker.walkingQueue.isRunning && attacker.settings.runEnergy >= 1.0 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 + private fun targetLocationFor(attacker: Entity, target: Entity): Location { + val targetSteps = CombatMovementPlanner.movementStepsThisTick(target) + val predictionSteps = targetSteps.coerceAtMost(movementStepsFor(attacker)) + return CombatMovementPlanner.predictTargetLocations(target, predictionSteps).lastOrNull() ?: target.location + } + + private fun movementStepsFor(attacker: Entity): Int { + return if (canRun(attacker)) { + 2 + } else { + 1 } - if (attacker.walkingQueue.isRunningBoth || attacker.walkingQueue.isRunDisabled) { - return false - } - if (attacker.settings.runEnergy < 1.0) { - return false - } - return CombatMovementPlanner.movementStepsThisTick(target) > 1 + } + + private fun canRun(attacker: Entity): Boolean { + return attacker is Player && attacker.walkingQueue.isRunningBoth && attacker.settings.runEnergy >= 1.0 } private fun stopUnreachableCombat(attacker: Entity) { diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt index 80a3dcd2c..cca823a4f 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt @@ -2,6 +2,7 @@ package core.game.node.entity.combat import core.ServerConstants import core.game.node.entity.Entity +import core.game.node.entity.player.Player import core.game.world.map.Location import core.game.world.map.Point import core.game.world.map.RegionManager @@ -52,7 +53,7 @@ object CombatMovementPlanner { if (queued.isEmpty()) { return emptyList() } - val steps = if (queued.first().isRunDisabled) 1 else maxSteps.coerceAtMost(2) + val steps = if (canMoveTwoStepsThisTick(target, queued)) maxSteps.coerceAtMost(2) else 1 return queued.take(steps).map { Location.create(it.x, it.y, target.location.z) } } @@ -62,7 +63,7 @@ object CombatMovementPlanner { if (queued.isEmpty()) { return 0 } - return if (target.walkingQueue.isRunningBoth && !queued.first().isRunDisabled && queued.size > 1) { + return if (canMoveTwoStepsThisTick(target, queued)) { 2 } else { 1 @@ -111,4 +112,11 @@ object CombatMovementPlanner { private fun movementPoints(target: Entity): List { return target.walkingQueue.queue.filter { it.direction != null } } + + private fun canMoveTwoStepsThisTick(target: Entity, queued: List): Boolean { + if (queued.size <= 1 || queued.first().isRunDisabled || !target.walkingQueue.isRunningBoth) { + return false + } + return target !is Player || target.settings.runEnergy >= 1.0 + } } diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index 9b2190c09..5371b276e 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -30,7 +30,7 @@ class CombatMovementTests { } @Test - fun meleeAttackerShouldMirrorRunningVictimAndKeepAttackPressure() { + fun meleeAttackerShouldMirrorRunningVictimWhenRunEnabledAndKeepAttackPressure() { TestUtils.getMockPlayer("combat_mirror_attacker").use { attacker -> TestUtils.getMockPlayer("combat_mirror_victim").use { victim -> val origin = arenaOrigin() @@ -38,6 +38,8 @@ class CombatMovementTests { place(victim, origin.transform(1, 0, 0)) configureMelee(attacker) configureMelee(victim) + enableRun(attacker) + enableRun(victim) enablePvp(attacker, victim) attacker.attack(victim) @@ -54,7 +56,7 @@ class CombatMovementTests { } @Test - fun meleeAttackerShouldQueueRunStepWhenCurrentTargetIsLeavingMeleeRange() { + fun meleeAttackerShouldQueueRunStepWhenRunEnabledAndCurrentTargetIsLeavingMeleeRange() { TestUtils.getMockPlayer("combat_active_mirror_attacker").use { attacker -> TestUtils.getMockPlayer("combat_active_mirror_victim").use { victim -> val origin = arenaOrigin() @@ -62,6 +64,8 @@ class CombatMovementTests { place(victim, origin.transform(1, 0, 0)) configureMelee(attacker) configureMelee(victim) + enableRun(attacker) + enableRun(victim) enablePvp(attacker, victim) CombatMovementIntents.clear() @@ -81,6 +85,69 @@ class CombatMovementTests { } } + @Test + fun meleeAttackerShouldNotForceRunWhenChasingRunningPvpTarget() { + TestUtils.getMockPlayer("combat_walk_attacker").use { attacker -> + TestUtils.getMockPlayer("combat_walk_victim").use { victim -> + val origin = arenaOrigin() + place(attacker, origin.transform(0, -1, 0)) + place(victim, origin.transform(1, 0, 0)) + configureMelee(attacker) + configureMelee(victim) + disableRun(attacker) + enableRun(victim) + enablePvp(attacker, victim) + CombatMovementIntents.clear() + + attacker.attack(victim) + queueRun(victim, origin.transform(8, 0, 0)) + GameWorld.Pulser.updateAll() + CombatMovementIntents.resolve() + + assertFalse(attacker.walkingQueue.isRunning, "Combat movement must not persist the transient running flag.") + assertFalse(attacker.walkingQueue.isRunningBoth, "Combat movement must not make the attacker run.") + + victim.walkingQueue.update() + attacker.walkingQueue.update() + + assertTrue(attacker.properties.combatPulse.isAttacking, "Combat should remain active while the target is moving.") + assertEquals(-1, attacker.walkingQueue.runDir, "Combat movement must not force run when run is off.") + assertFalse(attacker.settings.isRunToggled, "Combat movement must not toggle run on.") + assertEquals(100.0, attacker.settings.runEnergy, 0.0, "Walking combat chase must not drain run energy.") + } + } + } + + @Test + fun walkingMeleeAttackerShouldNotStopWhenRunningTargetTemporarilyBlocksFirstStep() { + TestUtils.getMockPlayer("combat_walk_blocked_attacker").use { attacker -> + TestUtils.getMockPlayer("combat_walk_blocked_victim").use { victim -> + val origin = arenaOrigin() + place(attacker, origin) + place(victim, origin.transform(1, 0, 0)) + configureMelee(attacker) + configureMelee(victim) + disableRun(attacker) + enableRun(victim) + enablePvp(attacker, victim) + CombatMovementIntents.clear() + + attacker.attack(victim) + queueRun(victim, origin.transform(8, 0, 0)) + GameWorld.Pulser.updateAll() + CombatMovementIntents.resolve() + + assertTrue( + attacker.properties.combatPulse.isAttacking, + "A moving target temporarily blocking the first walking step should not stop combat." + ) + assertFalse(receivedMessage(attacker, "I can't reach that!")) + assertFalse(attacker.walkingQueue.isRunningBoth, "Waiting for the next tick must not force running.") + assertEquals(100.0, attacker.settings.runEnergy, 0.0, "Waiting for a moving target must not drain run energy.") + } + } + } + @Test fun meleeAttackerShouldFollowVictimRunQueuedByLiveWalkPacket() { TestUtils.getMockPlayer("combat_packet_mirror_attacker").use { attacker -> @@ -438,6 +505,12 @@ class CombatMovementTests { player.settings.setRunToggled(true) } + private fun disableRun(player: Player) { + player.settings.runEnergy = 100.0 + player.settings.setRunToggled(false) + player.walkingQueue.setRunning(false) + } + private fun enablePvp(first: Entity, second: Entity) { core.game.world.GameWorld.settings!!.wild_pvp_enabled = true first.asPlayer().skullManager.isWilderness = true diff --git a/Server/src/test/kotlin/content/CombatTests.kt b/Server/src/test/kotlin/content/CombatTests.kt index ac57966b0..1eec4be78 100644 --- a/Server/src/test/kotlin/content/CombatTests.kt +++ b/Server/src/test/kotlin/content/CombatTests.kt @@ -170,6 +170,24 @@ class CombatTests { } } + @Test + fun combatMovementPlannerTreatsZeroEnergyRunningPlayerAsWalking() { + TestUtils.getMockPlayer("combatPlannerNoEnergyRunner").use { target -> + val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0) + target.location = origin + target.settings.runEnergy = 0.0 + target.walkingQueue.reset(true) + target.walkingQueue.addPath(origin.x + 3, origin.y) + + Assertions.assertEquals(1, CombatMovementPlanner.movementStepsThisTick(target)) + Assertions.assertEquals( + listOf(origin.transform(1, 0, 0)), + CombatMovementPlanner.predictTargetLocations(target) + ) + Assertions.assertEquals(origin.transform(1, 0, 0), CombatMovementPlanner.predictedTargetLocation(target)) + } + } + @Test fun combatMovementPlannerChoosesClosestTargetBorderTile() { TestUtils.getMockPlayer("combatPlannerAttacker").use { attacker ->