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 72aff9da0..c01dda316 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -405,6 +405,16 @@ object CombatMovementIntents { } } else { destinations.add(MovementDestination(targetLocation, pathfinder, allowPartialPath = true)) + if (chebyshevToFootprint(attacker, target, targetLocation) <= target.size()) { + val border = CombatMovementPlanner.borderTiles(target, targetLocation, attacker.size()) + val walkable = border.filter { RegionManager.isTeleportPermitted(it) } + val sorted = walkable.sortedWith( + compareBy { it.getDistance(attacker.location) }.thenBy { it.x }.thenBy { it.y } + ) + for (location in sorted) { + destinations.add(MovementDestination(location, pathfinder, allowPartialPath = true)) + } + } } trace.candidateCount += destinations.size return destinations @@ -695,6 +705,12 @@ object CombatMovementIntents { return dx * dx + dy * dy } + private fun chebyshevToFootprint(attacker: Entity, target: Entity, targetLocation: Location): Int { + val closestX = attacker.location.x.coerceIn(targetLocation.x, targetLocation.x + target.size() - 1) + val closestY = attacker.location.y.coerceIn(targetLocation.y, targetLocation.y + target.size() - 1) + return maxOf(kotlin.math.abs(attacker.location.x - closestX), kotlin.math.abs(attacker.location.y - closestY)) + } + private fun shouldUseTargetFootprintRoute(attacker: Entity, target: Entity, pathfinder: Pathfinder): Boolean { if (attacker.properties.combatPulse.style != CombatStyle.MELEE || occupiedTilesOverlap(attacker, target)) { return false diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index ddb9be9e5..dca05993c 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -1187,6 +1187,36 @@ class CombatMovementTests { } } + @Test + fun dumbMeleeNpcShouldStepToAttackTileWhenDiagonallyAdjacentOnOpenTerrain() { + TestUtils.getMockPlayer("combat_open_diagonal_adjacent_target").use { player -> + val origin = Location.create(3200, 3600, 0) + place(player, origin) + + val npc = NPC.create(100, origin.transform(1, 1, 0)) + npc.init() + try { + configureMelee(npc) + assertFalse(meleeReach(npc, player), "Precondition: NPC should be diagonally adjacent, not in melee range.") + + npc.attack(player) + CombatMovementIntents.clear() + CombatMovementIntents.request(npc, player) + CombatMovementIntents.resolve() + npc.walkingQueue.update() + + assertTrue( + meleeReach(npc, player), + "A diagonally-adjacent dumb NPC on open terrain should step to an orthogonally-adjacent attack tile." + ) + assertTrue(npc.properties.combatPulse.isAttacking) + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + @Test fun dumbMeleeNpcShouldNotSidestepAroundImmediateSafespotBlocker() { TestUtils.getMockPlayer("combat_cardinal_north_safespot_target").use { player ->