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 55648ee59..7cc659083 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -3,11 +3,12 @@ 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.Direction import core.game.world.map.Location import core.game.world.map.Point import core.game.world.map.path.Path import core.game.world.map.path.Pathfinder +import core.game.world.repository.Repository import java.util.LinkedHashMap /** @@ -16,6 +17,11 @@ import java.util.LinkedHashMap */ object CombatMovementIntents { private data class Intent(val attacker: Entity, val target: Entity) + private data class MovementDestination( + val location: Location, + val pathfinder: Pathfinder, + val allowPartialPath: Boolean + ) private data class CandidatePath(val steps: List, val projectedLocation: Location) private val intents = LinkedHashMap() @@ -99,8 +105,7 @@ object CombatMovementIntents { return } - val targetLocation = targetLocationFor(attacker, target) - val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation) + val candidates = movementDestinationsFor(attacker, target) var blockedByReservation = false for (candidate in candidates) { val candidatePath = pathTo(attacker, candidate) ?: continue @@ -115,7 +120,7 @@ object CombatMovementIntents { reservedTiles.addAll(projectedTiles) return } - if (!blockedByReservation && CombatMovementPlanner.movementStepsThisTick(target) == 0) { + if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target)) { stopUnreachableCombat(attacker) } } @@ -137,13 +142,76 @@ object CombatMovementIntents { return attacker !is NPC || !attacker.isNeverWalks } - private fun pathTo(attacker: Entity, destination: Location): CandidatePath? { - if (attacker.location == destination) { + private fun movementDestinationsFor(attacker: Entity, target: Entity): List { + val pathfinder = pathfinderFor(attacker) + if (attacker is NPC && pathfinder === Pathfinder.DUMB) { + return dumbNpcAttackDestinations(attacker, target).map { + MovementDestination(it, pathfinder, allowPartialPath = true) + } + } + + val targetLocation = targetLocationFor(attacker, target) + return CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation).map { + MovementDestination(it, pathfinder, allowPartialPath = false) + } + } + + private fun dumbNpcAttackDestinations(attacker: NPC, target: Entity): List { + val directions = LinkedHashSet() + directions.add(Direction.getLogicalDirection(target.centerLocation, attacker.centerLocation)) + + if (attacker.centerLocation.x < target.centerLocation.x) { + directions.add(Direction.WEST) + } else if (attacker.centerLocation.x > target.centerLocation.x) { + directions.add(Direction.EAST) + } + if (attacker.centerLocation.y < target.centerLocation.y) { + directions.add(Direction.SOUTH) + } else if (attacker.centerLocation.y > target.centerLocation.y) { + directions.add(Direction.NORTH) + } + + return directions.map { dumbNpcAttackDestination(attacker, target, it) } + } + + private fun dumbNpcAttackDestination(attacker: NPC, target: Entity, direction: Direction): Location { + val targetLocation = target.location + val minAlignedX = targetLocation.x - attacker.size() + 1 + val maxAlignedX = targetLocation.x + target.size() - 1 + val minAlignedY = targetLocation.y - attacker.size() + 1 + val maxAlignedY = targetLocation.y + target.size() - 1 + + return when (direction) { + Direction.WEST -> Location.create( + targetLocation.x - attacker.size(), + attacker.location.y.coerceIn(minAlignedY, maxAlignedY), + targetLocation.z + ) + Direction.EAST -> Location.create( + targetLocation.x + target.size(), + attacker.location.y.coerceIn(minAlignedY, maxAlignedY), + targetLocation.z + ) + Direction.SOUTH -> Location.create( + attacker.location.x.coerceIn(minAlignedX, maxAlignedX), + targetLocation.y - attacker.size(), + targetLocation.z + ) + else -> Location.create( + attacker.location.x.coerceIn(minAlignedX, maxAlignedX), + targetLocation.y + target.size(), + targetLocation.z + ) + } + } + + private fun pathTo(attacker: Entity, destination: MovementDestination): CandidatePath? { + if (attacker.location == destination.location) { return null } - val path = Pathfinder.find(attacker, destination, false, pathfinderFor(attacker)) - if (!path.reaches(destination)) { + val path = Pathfinder.find(attacker, destination.location, destination.allowPartialPath, destination.pathfinder) + if (!path.reaches(destination.location) && (!destination.allowPartialPath || path.points.isEmpty())) { return null } val steps = immediateMovementSteps(attacker, path) @@ -197,6 +265,10 @@ object CombatMovementIntents { return attacker is Player && attacker.walkingQueue.isRunningBoth && attacker.settings.runEnergy >= 1.0 } + private fun shouldStopUnreachableCombat(attacker: Entity, target: Entity): Boolean { + return attacker is Player && CombatMovementPlanner.movementStepsThisTick(target) == 0 + } + private fun stopUnreachableCombat(attacker: Entity) { attacker.properties.combatPulse.stop() attacker.walkingQueue.reset() diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index 5371b276e..2dc296a5f 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -463,6 +463,155 @@ class CombatMovementTests { } } + @Test + fun dumbMeleeNpcShouldNotRouteAroundSafespotObstacle() { + TestUtils.getMockPlayer("combat_safespot_target").use { player -> + val origin = arenaOrigin() + val blockedTiles = listOf( + origin.transform(1, 0, 0), + origin.transform(0, -1, 0) + ) + place(player, origin.transform(4, 0, 0)) + + val npc = NPC.create(100, origin) + npc.init() + try { + configureMelee(npc) + blockMovementTiles(blockedTiles) + + npc.attack(player) + CombatMovementIntents.clear() + CombatMovementIntents.request(npc, player) + CombatMovementIntents.resolve() + npc.walkingQueue.update() + + assertEquals( + origin, + npc.location, + "Default dumb NPC combat pathing should not choose alternate border tiles to route around safespots." + ) + assertTrue(npc.properties.combatPulse.isAttacking) + } finally { + unblockMovementTiles(blockedTiles) + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun dumbMeleeNpcShouldUseOpenCornerSideWhenDiagonallyFacingTarget() { + TestUtils.getMockPlayer("combat_diagonal_corner_target").use { player -> + val origin = arenaOrigin() + val northSide = origin.transform(0, 1, 0) + val westSide = origin.transform(-1, 0, 0) + place(player, origin) + + val npc = NPC.create(100, origin.transform(-1, 1, 0)) + npc.init() + try { + configureMelee(npc) + blockMovementTiles(listOf(northSide)) + + npc.attack(player) + CombatMovementIntents.clear() + CombatMovementIntents.request(npc, player) + CombatMovementIntents.resolve() + npc.walkingQueue.update() + + assertEquals( + westSide, + npc.location, + "A diagonal dumb NPC should try the other corner-adjacent side when its preferred side is blocked." + ) + assertTrue(meleeReach(npc, player)) + assertTrue(npc.properties.combatPulse.isAttacking) + } finally { + unblockMovementTiles(listOf(northSide)) + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun dumbMeleeNpcShouldNotRouteAroundWhenBothCornerSidesAreBlocked() { + TestUtils.getMockPlayer("combat_blocked_diagonal_corner_target").use { player -> + val origin = arenaOrigin() + val blockedTiles = listOf( + origin.transform(0, 1, 0), + origin.transform(-1, 0, 0) + ) + val npcStart = origin.transform(-1, 1, 0) + place(player, origin) + + val npc = NPC.create(100, npcStart) + npc.init() + try { + configureMelee(npc) + blockMovementTiles(blockedTiles) + + npc.attack(player) + CombatMovementIntents.clear() + CombatMovementIntents.request(npc, player) + CombatMovementIntents.resolve() + npc.walkingQueue.update() + + assertEquals( + npcStart, + npc.location, + "A diagonal dumb NPC should not rotate to the far side when both corner-adjacent attack tiles are blocked." + ) + assertTrue(npc.properties.combatPulse.isAttacking) + } finally { + unblockMovementTiles(blockedTiles) + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun dumbMeleeNpcShouldPrioritizeDirectSideWhenTargetRunsBehindObstacle() { + TestUtils.getMockPlayer("combat_running_safespot_target").use { player -> + val origin = arenaOrigin() + val obstacle = origin.transform(1, 0, 0) + val playerEnd = origin.transform(2, 0, 0) + val playerPath = listOf( + origin.transform(0, 1, 0), + origin.transform(1, 1, 0), + origin.transform(2, 1, 0), + playerEnd + ) + place(player, origin) + enableRun(player) + + val npc = NPC.create(100, origin.transform(-1, 0, 0)) + npc.init() + try { + configureMelee(npc) + blockMovementTiles(listOf(obstacle)) + + npc.attack(player) + queueRunPath(player, playerPath) + TestUtils.advanceTicks(6, false) + + assertEquals(playerEnd, player.location) + assertEquals( + origin, + npc.location, + "Default dumb NPCs should pursue the target-facing side, then stall when that side is blocked." + ) + assertFalse(meleeReach(npc, player)) + assertTrue(npc.properties.combatPulse.isAttacking) + } finally { + unblockMovementTiles(listOf(obstacle)) + npc.clear() + CombatMovementIntents.clear() + } + } + } + private fun arenaOrigin(): Location { return Location.create(3200, 3600, 0) } @@ -478,6 +627,13 @@ class CombatMovementTests { entity.walkingQueue.addPath(destination.x, destination.y) } + private fun queueRunPath(entity: Entity, path: List) { + entity.walkingQueue.reset(true) + for (location in path) { + entity.walkingQueue.addPath(location.x, location.y) + } + } + private fun configureMelee(entity: Entity) { entity.properties.attackStyle = WeaponInterface.AttackStyle( WeaponInterface.STYLE_AGGRESSIVE, diff --git a/docs/combat-movement-rewrite.md b/docs/combat-movement-rewrite.md index 47c4244e0..f97e7fbaa 100644 --- a/docs/combat-movement-rewrite.md +++ b/docs/combat-movement-rewrite.md @@ -128,10 +128,10 @@ Initial targets: 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. + `meleeAttackerShouldMirrorRunningVictimWhenRunEnabledAndKeepAttackPressure`. + 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 @@ -139,11 +139,9 @@ Initial targets: 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. + if the target's queued movement would leave that range. Added a regression + covering the active pulse/intent handoff so a run-enabled 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 @@ -153,3 +151,9 @@ Initial targets: 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. +- 2026-04-30: Corrected combat intent movement so PvP melee chase respects the + attacker's own run state. The resolver now clamps target prediction to the + attacker's current walk/run capacity, treats zero run energy as walking, and + no longer forces `WalkingQueue.reset(true)` when the attacker has run toggled + off. If a moving target temporarily blocks an exact walking path, combat waits + for the next tick instead of stopping as unreachable.