Forced run fix

This commit is contained in:
dam 2026-04-30 14:27:09 +03:00
parent 4d50efbddc
commit 64b6d858a0
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
4 changed files with 129 additions and 30 deletions

View file

@ -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<Point> {
private fun immediateMovementSteps(attacker: Entity, path: Path): 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 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) {

View file

@ -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<Point> {
return target.walkingQueue.queue.filter { it.direction != null }
}
private fun canMoveTwoStepsThisTick(target: Entity, queued: List<Point>): Boolean {
if (queued.size <= 1 || queued.first().isRunDisabled || !target.walkingQueue.isRunningBoth) {
return false
}
return target !is Player || target.settings.runEnergy >= 1.0
}
}

View file

@ -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

View file

@ -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 ->