From 4328d591f521fe553b1cb71a8f0124867919d123 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Sat, 9 May 2026 16:11:11 +0300 Subject: [PATCH] NPC wander fix --- .../main/core/game/node/entity/npc/NPC.java | 97 ++++++++++++++++--- .../src/test/kotlin/core/PathfinderTests.kt | 69 +++++++++++++ 2 files changed, 152 insertions(+), 14 deletions(-) diff --git a/Server/src/main/core/game/node/entity/npc/NPC.java b/Server/src/main/core/game/node/entity/npc/NPC.java index 18334251e..f888c2ac3 100644 --- a/Server/src/main/core/game/node/entity/npc/NPC.java +++ b/Server/src/main/core/game/node/entity/npc/NPC.java @@ -21,8 +21,11 @@ import content.global.skill.slayer.Tasks; import content.global.skill.summoning.familiar.Familiar; import core.game.world.map.Direction; import core.game.world.map.Location; +import core.game.world.map.Point; import core.game.world.map.RegionManager; import core.game.world.map.build.DynamicRegion; +import core.game.world.map.path.ClipMaskSupplier; +import core.game.world.map.path.Path; import core.game.world.map.path.Pathfinder; import core.game.world.update.flag.context.Animation; import core.game.world.update.flag.context.Graphics; @@ -466,25 +469,22 @@ public class NPC extends Entity { return; if (!getLocks().isInteractionLocked()) { if (!getLocks().isMovementLocked()) { + int effectiveWalkRadius = getWalkRadius(); if ( !pathBoundMovement - && walkRadius > 0 - && walkRadius <= 20 - && !getLocation().withinDistance(getProperties().getSpawnLocation(), (int)(walkRadius * 1.5)) + && effectiveWalkRadius > 0 + && effectiveWalkRadius <= 20 + && !getLocation().withinDistance(getProperties().getSpawnLocation(), getSpawnReturnDistance(effectiveWalkRadius)) && !getAttribute("no-spawn-return", false) ) { MovementPulse current = getAttribute("return-to-spawn-pulse"); if (current != null && current.isRunning()) return; - if(!isNeverWalks()){ - if(walkRadius == 0) - walkRadius = 3; - } if (aggressiveHandler != null) { - aggressiveHandler.setPauseTicks(walkRadius + 1); + aggressiveHandler.setPauseTicks(effectiveWalkRadius + 1); } - nextWalk = GameWorld.getTicks() + walkRadius + 1; + nextWalk = GameWorld.getTicks() + effectiveWalkRadius + 1; getLocks().lockMovement(100); getImpactHandler().setDisabledTicks(100); setAttribute("return-to-spawn", true); @@ -530,15 +530,84 @@ public class NPC extends Entity { setNextWalk(); Location l = getMovementDestination(); if (canMove(l)) { - if((Boolean) definition.getHandlers().getOrDefault("water_npc",false)){ - Pathfinder.findWater(this,l,true,Pathfinder.DUMB).walk(this); - } else { - Pathfinder.find(this, l, true, Pathfinder.DUMB).walk(this); - } + Path path = pathBoundMovement ? findMovementPath(l) : findRandomMovementPath(l); + path.walk(this); } return false; } + private Path findMovementPath(Location destination) { + if (isWaterNPC()) { + return Pathfinder.findWater(this, destination, true, Pathfinder.DUMB); + } + return Pathfinder.find(this, destination, true, Pathfinder.DUMB); + } + + private Path findRandomMovementPath(Location destination) { + if (isWaterNPC() || size() != 1) { + Path path = findMovementPath(destination); + return isRandomMovementPathWithinBounds(path) ? path : new Path(); + } + return findDirectRandomMovementPath(destination); + } + + private Path findDirectRandomMovementPath(Location destination) { + Path path = new Path(); + path.setSuccesful(true); + Location current = getLocation(); + int maxSteps = Math.min(14, Math.max(1, getSpawnReturnDistance(Math.max(1, getWalkRadius())))); + for (int steps = 0; !current.equals(destination) && steps < maxSteps; steps++) { + Direction direction = Direction.getDirection(current, destination); + if (direction == null || !canTakeRandomMovementStep(current, direction)) { + path.setSuccesful(false); + path.setMoveNear(!path.getPoints().isEmpty()); + break; + } + Location next = current.transform(direction); + if (!isWithinRandomMovementBounds(next)) { + path.setSuccesful(false); + path.setMoveNear(!path.getPoints().isEmpty()); + break; + } + path.getPoints().add(new Point(next.getX(), next.getY(), direction, direction.getStepX(), direction.getStepY())); + current = next; + } + if (!current.equals(destination) && !path.getPoints().isEmpty()) { + path.setMoveNear(true); + } + return path; + } + + private boolean canTakeRandomMovementStep(Location current, Direction direction) { + ClipMaskSupplier clipMaskSupplier = behavior != null ? behavior.getClippingSupplier(this) : null; + if (clipMaskSupplier == null) { + clipMaskSupplier = RegionManager::getClippingFlag; + } + return direction.canMoveFrom(current.getZ(), current.getX(), current.getY(), clipMaskSupplier); + } + + private boolean isRandomMovementPathWithinBounds(Path path) { + for (Point point : path.getPoints()) { + if (!isWithinRandomMovementBounds(Location.create(point.getX(), point.getY(), getLocation().getZ()))) { + return false; + } + } + return true; + } + + private boolean isWithinRandomMovementBounds(Location location) { + int walkRadius = getWalkRadius(); + return walkRadius <= 0 || location.withinDistance(getProperties().getSpawnLocation(), getSpawnReturnDistance(walkRadius)); + } + + private int getSpawnReturnDistance(int walkRadius) { + return (int) (walkRadius * 1.5); + } + + private boolean isWaterNPC() { + return (Boolean) definition.getHandlers().getOrDefault("water_npc", false); + } + public int getNextWalk() { return nextWalk; } diff --git a/Server/src/test/kotlin/core/PathfinderTests.kt b/Server/src/test/kotlin/core/PathfinderTests.kt index a3ff67fad..dacda2ac0 100644 --- a/Server/src/test/kotlin/core/PathfinderTests.kt +++ b/Server/src/test/kotlin/core/PathfinderTests.kt @@ -584,6 +584,56 @@ class PathfinderTests { } } + @Test fun npcReturnToSpawnShouldUseOverriddenWalkRadius() { + TestUtils.getMockPlayer("overriddenRadiusReturn").use { + val spawn = ServerConstants.HOME_LOCATION!! + val npc = object : NPC(1, spawn.transform(5, 0, 0)) { + override fun getWalkRadius(): Int { + return 3 + } + } + npc.isWalks = true + npc.isNeverWalks = false + npc.init() + npc.properties.spawnLocation = spawn + try { + npc.handleTickActions() + + Assertions.assertEquals(true, npc.getAttribute("return-to-spawn", false)) + } finally { + npc.clear() + } + } + } + + @Test fun randomWalkingNpcShouldNotRouteAroundBlockedLocalDestination() { + val origin = Location.create(3200, 3600, 0) + val blocked = origin.transform(1, 0, 0) + val destination = origin.transform(2, 0, 0) + val npc = FixedDestinationNPC(origin, destination, 3) + npc.isWalks = true + npc.isNeverWalks = false + npc.init() + npc.properties.spawnLocation = origin + RegionManager.addClippingFlag(blocked.z, blocked.x, blocked.y, false, movementBlockFlag) + try { + npc.resetWalk() + repeat(20) { + npc.handleTickActions() + } + + Assertions.assertFalse( + npc.walkingQueue.hasPath(), + "Random-walking NPCs should not take an RSMOD detour around a clipped boundary tile." + ) + npc.walkingQueue.update() + Assertions.assertEquals(origin, npc.location) + } finally { + RegionManager.removeClippingFlag(blocked.z, blocked.x, blocked.y, false, movementBlockFlag) + npc.clear() + } + } + @Test fun npcShouldReliablyReturnToSpawnEvenIfRegionUnloaded() { //spawn a player into the area just to make sure it ticks... TestUtils.getMockPlayer("areaunloadtest").use { p -> @@ -602,4 +652,23 @@ class PathfinderTests { Assertions.assertEquals(true, npc.location.getDistance(ServerConstants.HOME_LOCATION!!) <= 5) } } + + private class FixedDestinationNPC( + location: Location, + private val destination: Location, + private val radius: Int + ) : NPC(1, location) { + override fun getMovementDestination(): Location { + return destination + } + + override fun getWalkRadius(): Int { + return radius + } + } + + private val movementBlockFlag = Pathfinder.PREVENT_NORTH or + Pathfinder.PREVENT_EAST or + Pathfinder.PREVENT_SOUTH or + Pathfinder.PREVENT_WEST }