diff --git a/Server/pom.xml b/Server/pom.xml
index 5c93c44d2..71fad5580 100644
--- a/Server/pom.xml
+++ b/Server/pom.xml
@@ -63,6 +63,12 @@
[1.4.0,)
compile
+
+ org.rsmod
+ rsmod-pathfinder
+ 4.2.1
+ compile
+
mysql
mysql-connector-java
diff --git a/Server/src/main/content/global/handlers/item/PlayerPeltables.kt b/Server/src/main/content/global/handlers/item/PlayerPeltables.kt
index 931a39ef2..648c7e643 100644
--- a/Server/src/main/content/global/handlers/item/PlayerPeltables.kt
+++ b/Server/src/main/content/global/handlers/item/PlayerPeltables.kt
@@ -7,7 +7,6 @@ import core.game.node.entity.impl.Projectile
import core.game.node.entity.player.Player
import core.game.node.item.Item
import core.game.system.task.Pulse
-import core.game.world.map.path.Pathfinder
import core.game.world.update.flag.context.Graphics
import org.rs09.consts.Items
@@ -47,7 +46,7 @@ class PlayerPeltables : InteractionListener {
val other = node.asPlayer()
- if (!Pathfinder.find(player, other, false, Pathfinder.PROJECTILE).isSuccessful) {
+ if (!hasLineOfSight(player, other)) {
sendDialogue(player, "You can't reach them!")
return true
}
@@ -112,4 +111,4 @@ class PlayerPeltables : InteractionListener {
return equipped
}
-}
\ No newline at end of file
+}
diff --git a/Server/src/main/content/global/skill/thieving/StallThiefPulse.java b/Server/src/main/content/global/skill/thieving/StallThiefPulse.java
index b08185335..9dcf9e162 100644
--- a/Server/src/main/content/global/skill/thieving/StallThiefPulse.java
+++ b/Server/src/main/content/global/skill/thieving/StallThiefPulse.java
@@ -1,6 +1,8 @@
package content.global.skill.thieving;
import core.game.event.ResourceProducedEvent;
+import core.game.node.entity.combat.CombatPulse;
+import core.game.node.entity.combat.CombatReach;
import core.game.node.entity.combat.ImpactHandler;
import core.game.node.entity.skill.SkillPulse;
import core.game.node.entity.skill.Skills;
@@ -11,6 +13,8 @@ import core.game.node.scenery.Scenery;
import core.game.node.scenery.SceneryBuilder;
import core.game.world.GameWorld;
import core.game.world.map.RegionManager;
+import core.game.world.map.path.Path;
+import core.game.world.map.path.Pathfinder;
import core.game.world.update.flag.context.Animation;
import core.tools.RandomFunction;
import core.tools.StringUtils;
@@ -156,15 +160,85 @@ public final class StallThiefPulse extends SkillPulse {
player.sendMessage("A higher power smites you");
return false;
}
- for (NPC npc : RegionManager.getLocalNpcs(player.getLocation(), 8)) {
- if (!npc.getProperties().getCombatPulse().isAttacking() && (npc.getId() == 32 || npc.getId() == 2236)) {
- npc.sendChat("Hey! Get your hands off there!");
- npc.getProperties().getCombatPulse().attack(player);
- return false;
- }
+ NPC guard = findGuardForFailedSteal(player);
+ if (guard != null) {
+ guard.sendChat("Hey! Get your hands off there!");
+ guard.getProperties().getCombatPulse().attack(player);
+ return false;
}
}
return true;
}
+ /**
+ * Finds the guard that busts a failed steal. Guards whose attack can actually
+ * connect are preferred: NPC combat chase uses dumb pathing, so a guard on the far
+ * side of a stall (or inside the guardhouse next to the Ardougne market) would
+ * shout and then silently never reach the player. If no guard can reach, any guard
+ * within detection range still catches the thief - the steal must not succeed just
+ * because the witness is boxed in.
+ * @param player the thieving player.
+ * @return The catching guard, or {@code null} if no guard is in range.
+ */
+ public static NPC findGuardForFailedSteal(Player player) {
+ NPC catcher = findCatchingGuard(player);
+ return catcher != null ? catcher : findWitnessGuard(player);
+ }
+
+ /**
+ * Finds a guard within detection range whose attack can actually connect.
+ * @param player the thieving player.
+ * @return The catching guard, or {@code null} if no guard can reach the player.
+ */
+ private static NPC findCatchingGuard(Player player) {
+ for (NPC npc : RegionManager.getLocalNpcs(player.getLocation(), 8)) {
+ if (npc.getProperties().getCombatPulse().isAttacking() || (npc.getId() != 32 && npc.getId() != 2236)) {
+ continue;
+ }
+ if (canReachThief(npc, player)) {
+ return npc;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Finds any guard within detection range that witnesses the steal, even if it
+ * cannot reach the player. A guard already fighting someone else is too busy to
+ * notice, but one already (fruitlessly) chasing this player keeps catching them.
+ * @param player the thieving player.
+ * @return The witnessing guard, or {@code null} if none is in range.
+ */
+ private static NPC findWitnessGuard(Player player) {
+ for (NPC npc : RegionManager.getLocalNpcs(player.getLocation(), 8)) {
+ if (npc.getId() != 32 && npc.getId() != 2236) {
+ continue;
+ }
+ CombatPulse pulse = npc.getProperties().getCombatPulse();
+ if (pulse.isAttacking() && pulse.getVictim() != player) {
+ continue;
+ }
+ return npc;
+ }
+ return null;
+ }
+
+ /**
+ * Checks whether the guard's attack can actually connect: either it is already in
+ * melee reach, or its (dumb) chase route reaches the player.
+ * @param npc the guard.
+ * @param player the thieving player.
+ * @return {@code True} if the guard can reach the player.
+ */
+ private static boolean canReachThief(NPC npc, Player player) {
+ if (CombatReach.canMelee(npc, player, 1)) {
+ return true;
+ }
+ if (npc.getLocks().isMovementLocked()) {
+ return false;
+ }
+ Path path = Pathfinder.find(npc, player, true, Pathfinder.DUMB);
+ return path.isSuccessful() && !path.isMoveNear();
+ }
+
}
diff --git a/Server/src/main/core/ServerConstants.kt b/Server/src/main/core/ServerConstants.kt
index 8c5f493a4..1155841a9 100644
--- a/Server/src/main/core/ServerConstants.kt
+++ b/Server/src/main/core/ServerConstants.kt
@@ -235,6 +235,7 @@ class ServerConstants {
arrayOf(Location.create(2722, 4886, 0), "quest the golem 1"),
arrayOf(Location.create(2704, 5349, 0), "dorgeshuun", "dorg"),
arrayOf(Location.create(2711, 10132, 0), "brine rats"),
+ arrayOf(Location.create(2591, 4320, 0), "puro puro", "puro-puro", "puropuro", "puro", "impling maze"),
arrayOf(Location.create(2328, 3677, 0), "piscatoris"),
arrayOf(Location.create(2660, 3158, 0), "fishing trawler", "trawler"),
arrayOf(Location.create(2800, 3667, 0), "mountain camp"),
diff --git a/Server/src/main/core/game/interaction/InteractionListeners.kt b/Server/src/main/core/game/interaction/InteractionListeners.kt
index e3cab3e34..df9142e40 100644
--- a/Server/src/main/core/game/interaction/InteractionListeners.kt
+++ b/Server/src/main/core/game/interaction/InteractionListeners.kt
@@ -162,6 +162,29 @@ object InteractionListeners {
return destinationOverrides["$type:$option"]
}
+ private fun getOptionHandlerDestination(id: Int, option: String, node: Node): ((Entity, Node) -> Location?)? {
+ val handlers = ArrayList(2)
+ Option.defaultHandler(node, id, option)?.let { handlers.add(it) }
+ node.interaction?.options
+ ?.firstOrNull { it != null && it.name.equals(option, ignoreCase = true) }
+ ?.handler
+ ?.takeIf { it !in handlers }
+ ?.let { handlers.add(it) }
+ if (handlers.isEmpty()) {
+ return null
+ }
+ return { entity, target ->
+ handlers.firstNotNullOfOrNull { it.getDestination(entity, target) }
+ }
+ }
+
+ private fun getDestinationOverride(type: Int, id: Int, option: String, node: Node): ((Entity, Node) -> Location?)? {
+ return getOverride(type, id, option)
+ ?: getOverride(type, node.id)
+ ?: getOverride(type, option.toLowerCase())
+ ?: getOptionHandlerDestination(id, option, node)
+ }
+
@JvmStatic
fun run(id: Int, player: Player, node: Node, isEquip: Boolean): Boolean{
player.scripts.removeWeakScripts()
@@ -255,7 +278,7 @@ object InteractionListeners {
return true
}
- val destOverride = getOverride(type.ordinal, id, option) ?: getOverride(type.ordinal,node.id) ?: getOverride(type.ordinal,option.toLowerCase())
+ val destOverride = getDestinationOverride(type.ordinal, id, option, node)
if(type != IntType.ITEM && !isInstant(method)) {
if(player.locks.isMovementLocked) return false
diff --git a/Server/src/main/core/game/interaction/MovementPulse.java b/Server/src/main/core/game/interaction/MovementPulse.java
index b378a8a1f..2cd93e3b3 100644
--- a/Server/src/main/core/game/interaction/MovementPulse.java
+++ b/Server/src/main/core/game/interaction/MovementPulse.java
@@ -1,5 +1,6 @@
package core.game.interaction;
+import core.api.utils.Vector;
import core.game.node.Node;
import core.game.node.entity.Entity;
import core.game.node.entity.impl.WalkingQueue;
@@ -7,7 +8,6 @@ import core.game.node.entity.npc.NPC;
import core.game.node.entity.npc.NPCBehavior;
import core.game.node.entity.player.Player;
import core.game.system.task.Pulse;
-import core.game.world.GameWorld;
import core.game.world.map.Direction;
import core.game.world.map.Location;
import core.game.world.map.Point;
@@ -17,487 +17,503 @@ import core.game.world.map.path.Pathfinder;
import core.net.packet.PacketRepository;
import core.net.packet.context.PlayerContext;
import core.net.packet.out.ClearMinimapFlag;
-import kotlin.jvm.functions.Function2;
import kotlin.Pair;
-import core.tools.SystemLogger;
-import core.api.utils.Vector;
+import kotlin.jvm.functions.Function2;
-import content.region.wilderness.handlers.revenants.RevenantNPC;
+import java.util.ArrayList;
+import java.util.Deque;
import static core.api.ContentAPIKt.*;
-import java.util.Deque;
-
/**
* Handles a movement task.
*
* @author Emperor
*/
public abstract class MovementPulse extends Pulse {
-
- /**
- * The moving entity.
- */
- protected Entity mover;
-
- /**
- * The destination node.
- */
- protected Node destination;
-
- /**
- * The destination's last location.
- */
- private Location last;
-
- /**
- * The pathfinder.
- */
- private Pathfinder pathfinder;
-
- /**
- * If running should be forced.
- */
- private boolean forceRun;
-
- /**
- * The option handler.
- */
- private OptionHandler optionHandler;
-
- /**
- * The use with handler.
- */
- private UseWithHandler useHandler;
-
- /**
- * The destination flag.
- */
- private DestinationFlag destinationFlag;
-
- /**
- * The location to interact from.
- */
- private Location interactLocation;
-
- /**
- * If the path couldn't be fully found.
- */
- private boolean near;
-
- private Function2 overrideMethod;
-
- private Location previousLoc;
-
- /**
- * Constructs a new {@code MovementPulse} {@code Object}.
- *
- * @param mover The moving entity.
- * @param destination The destination node.
- */
- public MovementPulse(Entity mover, Node destination) {
- this(mover, destination, null, false);
- }
-
- /**
- * Constructs a new {@code MovementPulse} {@code Object}.
- *
- * @param mover The moving entity.
- * @param destination The destination node.
- * @param forceRun If the entity is forced to run.
- */
- public MovementPulse(Entity mover, Node destination, boolean forceRun) {
- this(mover, destination, null, forceRun);
- }
-
- /**
- * Constructs a new {@code MovementPulse} {@code Object}.
- *
- * @param mover The moving entity.
- * @param destination The destination node.
- * @param pathfinder The pathfinder to use.
- */
- public MovementPulse(Entity mover, Node destination, Pathfinder pathfinder) {
- this(mover, destination, pathfinder, false);
- }
-
- /**
- * Constructs a new {@code MovementPulse} {@code Object}.
- *
- * @param mover The moving entity.
- * @param destination The destination node.
- * @param optionHandler The option handler used.
- */
- public MovementPulse(Entity mover, Node destination, OptionHandler optionHandler) {
- this(mover, destination, null, false);
- this.optionHandler = optionHandler;
- }
-
- /**
- * Constructs a new {@code MovementPulse} {@code Object}.
- *
- * @param mover The moving entity.
- * @param destination The destination node.
- * @param useHandler The use with handler used.
- */
- public MovementPulse(Entity mover, Node destination, UseWithHandler useHandler) {
- this(mover, destination, null, false);
- this.useHandler = useHandler;
- }
-
- /**
- * Constructs a new {@code MovementPulse} {@code Object}.
- *
- * @param mover The moving entity.
- * @param destination The destination node.
- * @param destinationFlag The destination flag.
- */
- public MovementPulse(Entity mover, Node destination, DestinationFlag destinationFlag) {
- this(mover, destination, null, false);
- this.destinationFlag = destinationFlag;
- }
-
- public MovementPulse(Entity mover, Node destination, DestinationFlag destinationFlag, Function2 method){
- this(mover,destination,null,false);
- this.destinationFlag = destinationFlag;
- this.overrideMethod = method;
- }
-
- /**
- * Constructs a new {@code MovementPulse} {@code Object}.
- *
- * @param mover The moving entity.
- * @param destination The destination node.
- * @param pathfinder The pathfinder to use.
- * @param forceRun If the entity is forced to run.
- */
- public MovementPulse(Entity mover, Node destination, Pathfinder pathfinder, boolean forceRun) {
- super(1, mover, destination);
- this.mover = mover;
- this.destination = destination;
- if (pathfinder == null) {
- if (mover instanceof Player) {
- this.pathfinder = Pathfinder.SMART;
- } else if (mover instanceof NPC) {
- NPC npc = (NPC)mover;
- NPCBehavior behavior = npc.behavior;
- Pathfinder pf = behavior != null ? behavior.getPathfinderOverride(npc) : null;
- this.pathfinder = pf != null ? pf : Pathfinder.DUMB;
- } else {
- this.pathfinder = Pathfinder.DUMB;
- }
- } else {
- this.pathfinder = pathfinder;
- }
- this.forceRun = forceRun;
-
- if (destination instanceof NPC || destination instanceof Player)
- destinationFlag = DestinationFlag.ENTITY;
-
- if (mover.currentMovement != null) {
- mover.currentMovement.stop();
- mover.getWalkingQueue().reset();
- }
- mover.currentMovement = this;
- }
-
- private void clearInferiorScripts() {
- mover.scripts.removeWeakScripts();
- mover.scripts.removeNormalScripts();
- }
-
- @Override
- public boolean update() {
- if (!mover.getViewport().getRegion().isActive())
- return false;
-
- if (!isRunning()) return true;
-
- if (!validate()) {
- stop();
- return true;
- }
-
- clearInferiorScripts();
-
- mover.face(null);
- if (canInteractWithoutMoving()) {
- if (interactImmediately()) {
- stop();
- return true;
- }
+
+ /**
+ * The moving entity.
+ */
+ protected Entity mover;
+
+ /**
+ * The destination node.
+ */
+ protected Node destination;
+
+ /**
+ * The destination's last location.
+ */
+ private Location last;
+
+ /**
+ * The pathfinder.
+ */
+ private Pathfinder pathfinder;
+
+ /**
+ * If running should be forced.
+ */
+ private boolean forceRun;
+
+ /**
+ * The option handler.
+ */
+ private OptionHandler optionHandler;
+
+ /**
+ * The use with handler.
+ */
+ private UseWithHandler useHandler;
+
+ /**
+ * The destination flag.
+ */
+ private DestinationFlag destinationFlag;
+
+ /**
+ * The location to interact from.
+ */
+ private Location interactLocation;
+
+ /**
+ * If the path couldn't be fully found.
+ */
+ private boolean near;
+
+ private Function2 overrideMethod;
+
+ private Location previousLoc;
+
+ private boolean explicitInteractionLocation;
+
+ /**
+ * Constructs a new {@code MovementPulse} {@code Object}.
+ *
+ * @param mover The moving entity.
+ * @param destination The destination node.
+ */
+ public MovementPulse(Entity mover, Node destination) {
+ this(mover, destination, null, false);
}
- updatePath();
-
- if (tryInteract()) {
- stop();
- return true;
- }
-
- return false;
- }
-
- private boolean tryInteract() {
- Location ml = mover.getLocation();
- // Allow being within 1 square of moving entities to interact with them.
- int radius = destination instanceof Entity && ((Entity)destination).getWalkingQueue().hasPath() ? 1 : 0;
- if (interactLocation == null)
- return false;
- boolean atInteractLocation = Math.max(Math.abs(ml.getX() - interactLocation.getX()), Math.abs(ml.getY() - interactLocation.getY())) <= radius;
- // Check if already in a valid interaction position for entity destinations
- boolean canInteractFromCurrentPosition = false;
- if (!atInteractLocation && destination instanceof Entity) {
- Entity target = (Entity) destination;
- Location dl = target.getLocation();
- boolean onSameTile = ml.getX() == dl.getX() && ml.getY() == dl.getY() && ml.getZ() == dl.getZ();
- if (!onSameTile) {
- canInteractFromCurrentPosition = Pathfinder.canInteract(
- ml.getX(), ml.getY(), mover.size(),
- dl.getX(), dl.getY(), target.size(), target.size(),
- 0, // walkFlag - "can interact from any unblocked direction"
- ml.getZ(),
- RegionManager::getClippingFlag
- );
- }
+
+ /**
+ * Constructs a new {@code MovementPulse} {@code Object}.
+ *
+ * @param mover The moving entity.
+ * @param destination The destination node.
+ * @param forceRun If the entity is forced to run.
+ */
+ public MovementPulse(Entity mover, Node destination, boolean forceRun) {
+ this(mover, destination, null, forceRun);
}
- if (atInteractLocation || canInteractFromCurrentPosition) {
- try {
- if (near || pulse()) {
- if (mover instanceof Player) {
- if (near) {
- ((Player) mover).getPacketDispatch().sendMessage("I can't reach that.");
- }
- PacketRepository.send(ClearMinimapFlag.class, new PlayerContext((Player) mover));
- }
- stop();
- return true;
- }
- } catch (Exception e){
- e.printStackTrace();
- stop();
- }
- }
- return false;
- }
-
- /**
- * Checks if the mover can interact with an entity destination from their
- * current position without needing to move.
- *
- * @return true if the mover can interact without moving
- */
- private boolean canInteractWithoutMoving() {
- if (!(destination instanceof Entity)) {
- return false;
+
+ /**
+ * Constructs a new {@code MovementPulse} {@code Object}.
+ *
+ * @param mover The moving entity.
+ * @param destination The destination node.
+ * @param pathfinder The pathfinder to use.
+ */
+ public MovementPulse(Entity mover, Node destination, Pathfinder pathfinder) {
+ this(mover, destination, pathfinder, false);
}
- Entity target = (Entity) destination;
- Location ml = mover.getLocation();
- Location dl = target.getLocation();
- if (ml.getX() == dl.getX() && ml.getY() == dl.getY() && ml.getZ() == dl.getZ()) {
- return false;
+
+ /**
+ * Constructs a new {@code MovementPulse} {@code Object}.
+ *
+ * @param mover The moving entity.
+ * @param destination The destination node.
+ * @param optionHandler The option handler used.
+ */
+ public MovementPulse(Entity mover, Node destination, OptionHandler optionHandler) {
+ this(mover, destination, null, false);
+ this.optionHandler = optionHandler;
}
- if (isInsideEntity(mover.getLocation())) {
- return false;
+
+ /**
+ * Constructs a new {@code MovementPulse} {@code Object}.
+ *
+ * @param mover The moving entity.
+ * @param destination The destination node.
+ * @param useHandler The use with handler used.
+ */
+ public MovementPulse(Entity mover, Node destination, UseWithHandler useHandler) {
+ this(mover, destination, null, false);
+ this.useHandler = useHandler;
}
- if (target.getWalkingQueue().hasPath()) { // For moving entities, allow interaction from 1 tile away
- int distance = Math.max(
- Math.abs(ml.getX() - dl.getX()),
- Math.abs(ml.getY() - dl.getY())
- );
- if (distance <= target.size()) {
- return true;
- }
+
+ /**
+ * Constructs a new {@code MovementPulse} {@code Object}.
+ *
+ * @param mover The moving entity.
+ * @param destination The destination node.
+ * @param destinationFlag The destination flag.
+ */
+ public MovementPulse(Entity mover, Node destination, DestinationFlag destinationFlag) {
+ this(mover, destination, null, false);
+ this.destinationFlag = destinationFlag;
}
- return Pathfinder.canInteract(
- ml.getX(), ml.getY(), mover.size(),
- dl.getX(), dl.getY(), target.size(), target.size(),
- 0, // walkFlag - "can interact from any unblocked direction"
- ml.getZ(),
- RegionManager::getClippingFlag
- );
- }
-
- /**
- * Immediately performs the interaction without pathfinding.
- * Called when the player is already in a valid interaction position.
- *
- * @return true if interaction was successful and pulse should stop
- */
- private boolean interactImmediately() {
- if (destination instanceof Entity) {
- mover.face((Entity) destination);
+
+ public MovementPulse(Entity mover, Node destination, DestinationFlag destinationFlag, Function2 method) {
+ this(mover, destination, null, false);
+ this.destinationFlag = destinationFlag;
+ this.overrideMethod = method;
}
- try {
- if (pulse()) {
- if (mover instanceof Player) {
- PacketRepository.send(ClearMinimapFlag.class, new PlayerContext((Player) mover));
+
+ /**
+ * Constructs a new {@code MovementPulse} {@code Object}.
+ *
+ * @param mover The moving entity.
+ * @param destination The destination node.
+ * @param pathfinder The pathfinder to use.
+ * @param forceRun If the entity is forced to run.
+ */
+ public MovementPulse(Entity mover, Node destination, Pathfinder pathfinder, boolean forceRun) {
+ super(1, mover, destination);
+ this.mover = mover;
+ this.destination = destination;
+ if (pathfinder == null) {
+ if (mover instanceof Player) {
+ this.pathfinder = Pathfinder.SMART;
+ } else if (mover instanceof NPC) {
+ NPC npc = (NPC) mover;
+ NPCBehavior behavior = npc.behavior;
+ Pathfinder pf = behavior != null ? behavior.getPathfinderOverride(npc) : null;
+ this.pathfinder = pf != null ? pf : Pathfinder.DUMB;
+ } else {
+ this.pathfinder = Pathfinder.DUMB;
+ }
+ } else {
+ this.pathfinder = pathfinder;
}
- return true;
- }
- } catch (Exception e) {
- e.printStackTrace();
+ this.forceRun = forceRun;
+
+ if (destination instanceof NPC || destination instanceof Player) destinationFlag = DestinationFlag.ENTITY;
+
+ if (mover.currentMovement != null) {
+ mover.currentMovement.stop();
+ mover.getWalkingQueue().reset();
+ }
+ mover.currentMovement = this;
}
- return false;
- }
-
- private boolean validate() {
- if (mover == null || destination == null || mover.getViewport().getRegion() == null || hasInactiveNode()) {
- return false;
- }
- return isRunning();
- }
-
- @Override
- public void stop() {
- super.stop();
- if (destination instanceof Entity) {
- mover.face(null);
- }
- last = null;
- }
-
- /**
- * Finds a path to the destination, if necessary.
- */
- private boolean usingTruncatedPath = false;
- private boolean isMoveNearSet = false;
- public void updatePath() {
- if (mover instanceof NPC && mover.asNpc().isNeverWalks()) {
- return;
- }
- if(destination == null || destination.getLocation() == null){
- return;
- }
-
- Location loc = null;
-
- if (optionHandler != null) {
- loc = optionHandler.getDestination(mover, destination);
- }
- else if (useHandler != null) {
- loc = useHandler.getDestination((Player) mover, destination);
- }
- else if (isInsideEntity(mover.getLocation())) {
- loc = findBorderLocation();
- }
-
- if (loc == null && destinationFlag != null && overrideMethod == null) {
- loc = destinationFlag.getDestination(mover, destination);
- }
- else if(loc == null && overrideMethod != null){
- loc = overrideMethod.invoke(mover,destination);
- if(loc == destination.getLocation() && destinationFlag != null) loc = destinationFlag.getDestination(mover,destination);
- else if (loc == destination.getLocation()) loc = null;
- }
-
- if (destination instanceof NPC && mover.getProperties().getCombatPulse().getVictim() != destination)
- loc = checkForEntityPathInterrupt(loc != null ? loc : destination.getLocation());
-
- if (interactLocation == null)
- interactLocation = loc;
-
- if (destination instanceof Entity || interactLocation == null || (mover.getWalkingQueue().getQueue().size() <= 1 && interactLocation.getDistance(mover.getLocation()) > 0) || (usingTruncatedPath && destination.getLocation().getDistance(mover.getLocation()) < 14)) {
- if (!checkAllowMovement())
- return;
- if (destination instanceof Entity && previousLoc != null && previousLoc.equals(loc) && mover.getWalkingQueue().hasPath())
- return;
-
- Path path;
- Pair truncation = truncateLoc(mover, loc != null ? loc : destination.getLocation());
- if (truncation.getFirst()) {
- path = Pathfinder.find(mover, truncation.getSecond(), true, pathfinder);
- usingTruncatedPath = true;
- } else {
- path = Pathfinder.find(mover, loc != null ? loc : destination, true, pathfinder);
- interactLocation = null; //reset interactLocation so the below code can set it to the properly-pathfound last bit of path.
- usingTruncatedPath = false;
- }
- near = !path.isSuccessful() || path.isMoveNear();
-
- if (!path.getPoints().isEmpty()) {
- Point point = path.getPoints().getLast();
- if (forceRun) {
- mover.getWalkingQueue().reset(forceRun);
- } else {
- mover.getWalkingQueue().reset();
- }
- int size = path.getPoints().toArray().length;
- Deque points = path.getPoints();
- for (int i = 0; i < size; i++) {
- point = path.getPoints().pop();
- mover.getWalkingQueue().addPath(point.getX(), point.getY());
- if (destination instanceof Entity) {
- mover.face((Entity) destination);
- } else {
- mover.face(null);
- }
-
- if (i == size - 1 && interactLocation == null)
- interactLocation = Location.create(point.getX(), point.getY(), mover.getLocation().getZ());
- }
- }
- previousLoc = loc;
- }
- last = destination.getLocation();
- if (mover instanceof Player && mover.getAttribute("draw-intersect", false)) {
- clearHintIcon((Player) mover);
- registerHintIcon((Player) mover, interactLocation, 5);
- }
- }
-
- private boolean checkAllowMovement() {
- boolean canMove = true;
- if (destination instanceof Entity) {
- Entity e = (Entity) destination;
- Location l = e.getLocation();
- Deque npcPath = e.getWalkingQueue().getQueue();
- if (e.getWalkingQueue().hasPath() && e.getProperties().getCombatPulse().isRunning() && e.getProperties().getCombatPulse().getVictim() == mover)
- canMove = false;
- if (!canMove) { //If we normally shouldn't move, but the NPC's pathfinding is not letting them move, then move.
- if (npcPath.size() == 1) {
- Point pathElement = npcPath.peek();
- if (pathElement.getX() == l.getX() && pathElement.getY() == l.getY())
- canMove = true;
- }
- }
- }
- return canMove;
- }
-
+
+ private void clearInferiorScripts() {
+ mover.scripts.removeWeakScripts();
+ mover.scripts.removeNormalScripts();
+ }
+
+ @Override
+ public boolean update() {
+ if (!mover.getViewport().getRegion().isActive()) return false;
+
+ if (!isRunning()) return true;
+
+ if (!validate()) {
+ stop();
+ return true;
+ }
+
+ clearInferiorScripts();
+
+ mover.face(null);
+ if (canInteractWithoutMoving()) {
+ if (interactImmediately()) {
+ stop();
+ return true;
+ }
+ }
+ updatePath();
+
+ if (tryInteract()) {
+ stop();
+ return true;
+ }
+
+ return false;
+ }
+
+ private boolean tryInteract() {
+ Location ml = mover.getLocation();
+ if (ml == null || interactLocation == null) return false;
+ boolean atInteractLocation = ml.equals(interactLocation);
+ if (destination instanceof Entity) {
+ boolean canInteractFromCurrentPosition = canInteractWithEntityFromCurrentPosition((Entity) destination);
+ if (!canInteractFromCurrentPosition && !(near && atInteractLocation) && !(hasExplicitInteractionLocation() && atInteractLocation)) {
+ return false;
+ }
+ if (canInteractFromCurrentPosition) {
+ near = false;
+ }
+ } else if (!atInteractLocation) {
+ return false;
+ }
+ try {
+ if (near || pulse()) {
+ if (mover instanceof Player) {
+ if (near) {
+ sendMessage((Player) mover, "I can't reach that.");
+ }
+ PacketRepository.send(ClearMinimapFlag.class, new PlayerContext((Player) mover));
+ }
+ stop();
+ return true;
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ stop();
+ }
+ return false;
+ }
+
+ /**
+ * Checks if the mover can interact with an entity destination from their
+ * current position without needing to move.
+ *
+ * @return true if the mover can interact without moving
+ */
+ private boolean canInteractWithoutMoving() {
+ if (!(destination instanceof Entity)) {
+ return false;
+ }
+ return canInteractWithEntityFromCurrentPosition((Entity) destination);
+ }
+
+ private boolean canInteractWithEntityFromCurrentPosition(Entity target) {
+ return canInteractWithEntityFrom(mover.getLocation(), target);
+ }
+
+ private boolean canInteractWithEntityFrom(Location source, Entity target) {
+ if (source == null || target == null) {
+ return false;
+ }
+ Location dl = target.getLocation();
+ if (dl == null) {
+ return false;
+ }
+ if (source.getZ() != dl.getZ()) {
+ return false;
+ }
+ if (Pathfinder.isStandingIn(source.getX(), source.getY(), mover.size(), mover.size(), dl.getX(), dl.getY(), target.size(), target.size())) {
+ return false;
+ }
+ return Pathfinder.canInteract(source.getX(),
+ source.getY(),
+ mover.size(),
+ dl.getX(),
+ dl.getY(),
+ target.size(),
+ target.size(),
+ 0, // walkFlag - "can interact from any unblocked direction"
+ source.getZ(),
+ null);
+ }
+
+ private boolean hasExplicitInteractionLocation() {
+ return explicitInteractionLocation;
+ }
+
+ /**
+ * Immediately performs the interaction without pathfinding.
+ * Called when the player is already in a valid interaction position.
+ *
+ * @return true if interaction was successful and pulse should stop
+ */
+ private boolean interactImmediately() {
+ if (destination instanceof Entity) {
+ mover.face((Entity) destination);
+ }
+ try {
+ if (pulse()) {
+ if (mover instanceof Player) {
+ PacketRepository.send(ClearMinimapFlag.class, new PlayerContext((Player) mover));
+ }
+ return true;
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ return false;
+ }
+
+ private boolean validate() {
+ if (mover == null || destination == null || mover.getViewport().getRegion() == null || hasInactiveNode()) {
+ return false;
+ }
+ return isRunning();
+ }
+
+ @Override
+ public void stop() {
+ super.stop();
+ if (destination instanceof Entity) {
+ mover.face(null);
+ }
+ last = null;
+ }
+
+ /**
+ * Finds a path to the destination, if necessary.
+ */
+ private boolean usingTruncatedPath = false;
+ private boolean isMoveNearSet = false;
+
+ public void updatePath() {
+ if (mover instanceof NPC && mover.asNpc().isNeverWalks()) {
+ return;
+ }
+ if (mover.getLocation() == null || destination == null || destination.getLocation() == null) {
+ return;
+ }
+
+ Location loc = null;
+ boolean explicitLocation = false;
+
+ if (optionHandler != null) {
+ loc = optionHandler.getDestination(mover, destination);
+ explicitLocation = loc != null;
+ } else if (useHandler != null) {
+ loc = useHandler.getDestination((Player) mover, destination);
+ explicitLocation = loc != null;
+ } else if (isInsideEntity(mover.getLocation())) {
+ loc = findBorderLocation();
+ }
+
+ if (loc == null && destinationFlag != null && overrideMethod == null) {
+ loc = destinationFlag.getDestination(mover, destination);
+ } else if (loc == null && overrideMethod != null) {
+ loc = overrideMethod.invoke(mover, destination);
+ explicitLocation = loc != null;
+ if (loc == destination.getLocation() && destinationFlag != null) {
+ loc = destinationFlag.getDestination(mover, destination);
+ explicitLocation = false;
+ } else if (loc == destination.getLocation()) {
+ loc = null;
+ explicitLocation = false;
+ }
+ }
+
+ if (!explicitLocation && destination instanceof NPC && mover.getProperties().getCombatPulse().getVictim() != destination)
+ loc = checkForEntityPathInterrupt(loc != null ? loc : destination.getLocation());
+
+ explicitInteractionLocation = explicitLocation;
+ if (interactLocation == null) interactLocation = loc;
+
+ if (destination instanceof Entity || interactLocation == null || (mover.getWalkingQueue().getQueue()
+ .size() <= 1 && interactLocation.getDistance(mover.getLocation()) > 0) || (usingTruncatedPath && destination.getLocation()
+ .getDistance(mover.getLocation()) < 14)) {
+ if (!checkAllowMovement()) return;
+ if (destination instanceof Entity && previousLoc != null && previousLoc.equals(loc) && hasQueuedMovement(mover)) return;
+
+ Path path;
+ Pair truncation = truncateLoc(mover, loc != null ? loc : destination.getLocation());
+ if (truncation.getFirst()) {
+ path = Pathfinder.find(mover, truncation.getSecond(), true, pathfinder);
+ usingTruncatedPath = true;
+ } else {
+ path = Pathfinder.find(mover, loc != null ? loc : destination, true, pathfinder);
+ interactLocation = null; //reset interactLocation so the below code can set it to the properly-pathfound last bit of path.
+ usingTruncatedPath = false;
+ }
+ near = !path.isSuccessful() || path.isMoveNear();
+
+ if (!path.getPoints().isEmpty()) {
+ Point point = path.getPoints().getLast();
+ if (forceRun) {
+ mover.getWalkingQueue().reset(forceRun);
+ } else {
+ mover.getWalkingQueue().reset();
+ }
+ int size = path.getPoints().toArray().length;
+ Deque points = path.getPoints();
+ Location lastQueuedEntityLocation = null;
+ for (int i = 0; i < size; i++) {
+ point = path.getPoints().pop();
+ Location pointLocation = Location.create(point.getX(), point.getY(), mover.getLocation().getZ());
+ boolean currentPathPoint = pointLocation.equals(mover.getLocation());
+ if (!currentPathPoint && shouldTruncateEntityPath() && overlapsEntityFootprint(pointLocation, (Entity) destination)) {
+ if (interactLocation == null && lastQueuedEntityLocation != null) {
+ interactLocation = lastQueuedEntityLocation;
+ }
+ break;
+ }
+ mover.getWalkingQueue().addPath(point.getX(), point.getY());
+ if (destination instanceof Entity) {
+ mover.face((Entity) destination);
+ } else {
+ mover.face(null);
+ }
+
+ lastQueuedEntityLocation = pointLocation;
+ if (!currentPathPoint && shouldTruncateEntityPath() && canInteractWithEntityFrom(pointLocation, (Entity) destination)) {
+ interactLocation = pointLocation;
+ break;
+ }
+ if (i == size - 1 && interactLocation == null) interactLocation = pointLocation;
+ }
+ } else if (interactLocation == null && path.isSuccessful() && !path.isMoveNear()) {
+ interactLocation = mover.getLocation();
+ }
+ previousLoc = loc;
+ }
+ last = destination.getLocation();
+ if (mover instanceof Player && mover.getAttribute("draw-intersect", false)) {
+ clearHintIcon((Player) mover);
+ registerHintIcon((Player) mover, interactLocation, 5);
+ }
+ }
+
+ private boolean checkAllowMovement() {
+ boolean canMove = true;
+ if (destination instanceof Entity) {
+ Entity e = (Entity) destination;
+ Location l = e.getLocation();
+ Deque npcPath = e.getWalkingQueue().getQueue();
+ if (hasQueuedMovement(e) && e.getProperties().getCombatPulse().isRunning() && e.getProperties().getCombatPulse().getVictim() == mover)
+ canMove = false;
+ if (!canMove) { //If we normally shouldn't move, but the NPC's pathfinding is not letting them move, then move.
+ if (npcPath.size() == 1) {
+ Point pathElement = npcPath.peek();
+ if (pathElement.getX() == l.getX() && pathElement.getY() == l.getY()) canMove = true;
+ }
+ }
+ }
+ return canMove;
+ }
+
private Location checkForEntityPathInterrupt(Location loc) {
Location ml = mover.getLocation();
- Location dl = destination.getLocation();
// Lead the target if they're walking/running, unless they're already within interaction range
if (loc != null && destination instanceof Entity) {
WalkingQueue wq = ((Entity) destination).getWalkingQueue();
- if (wq.hasPath()) {
- Point[] points = wq.getQueue().toArray(new Point[0]);
+ if (hasQueuedMovement((Entity) destination)) {
+ Point[] points = queuedMovementPoints(wq);
if (points.length > 0) {
Point p = points[0];
Point predictiveIntersection = null;
+ int moverSpeed = mover.getWalkingQueue().isRunningBoth() ? 2 : 1;
for (int i = 0; i < points.length; i++) {
Location closestBorder = getClosestBorderToPoint(points[i], loc.getZ());
-
+
if (!RegionManager.isTeleportPermitted(closestBorder)) { // A nasty hack to discard invalid intersection points
continue;
}
int moverDist = Math.max(Math.abs(ml.getX() - closestBorder.getX()), Math.abs(ml.getY() - closestBorder.getY()));
- float movementRatio = moverDist / (float) ((i + 1) / (mover.getWalkingQueue().isRunning() ? 2 : 1));
- if (predictiveIntersection == null && movementRatio <= 1.0) { //try to predict an intersection point on the path if possible
+ float ticksToReach = moverDist / (float) moverSpeed;
+ if (predictiveIntersection == null && ticksToReach <= i + 1) { //try to predict an intersection point on the path if possible
predictiveIntersection = points[i];
break;
}
- // Otherwise, we target the farthest point along target's planned movement that's within 1 tick's running,
- // this ensures the player will run to catch up to the target if able.
- if (moverDist <= 2) {
+ // Otherwise, target the farthest point along the target's planned movement that's within one mover tick.
+ if (moverDist <= moverSpeed) {
p = points[i];
}
}
- if (predictiveIntersection != null)
- p = predictiveIntersection;
-
+ if (predictiveIntersection != null) p = predictiveIntersection;
+
Location endLoc = getClosestBorderToPoint(p, loc.getZ());
-
+
if (!RegionManager.isTeleportPermitted(endLoc)) { // Basically a prayer
return loc;
}
@@ -507,139 +523,192 @@ public abstract class MovementPulse extends Pulse {
}
return loc;
}
-
- private Location getClosestBorderToPoint (Point p, int plane) {
- Vector pathDiff = Vector.betweenLocs (destination.getLocation(), Location.create(p.getX(), p.getY(), plane));
- Location predictedCenterPos = (destination.getMathematicalCenter().plus(pathDiff)).toLocation(plane);
- Vector toPlayerNormalized = Vector.betweenLocs(predictedCenterPos, mover.getCenterLocation()).normalized();
- return predictedCenterPos.transform(toPlayerNormalized.times(destination.size() + 1));
- }
-
-
- private Location findBorderLocation() {
- return findBorderLocation(destination.getLocation());
- }
-
- /**
- * Finds the closest location next to the node.
- *
- * @return The location to walk to.
- */
- private Location findBorderLocation(Location centerDestLoc) {
- int size = destination.size();
- Location centerDest = centerDestLoc.transform(size >> 1, size >> 1, 0);
- Location center = mover.getLocation().transform(mover.size() >> 1, mover.size() >> 1, 0);
- Direction direction = Direction.getLogicalDirection(centerDest, center);
- Location delta = Location.getDelta(centerDestLoc, mover.getLocation());
- main:
- for (int i = 0; i < 4; i++) {
- int amount = 0;
- switch (direction) {
- case NORTH:
- amount = size - delta.getY();
- break;
- case EAST:
- amount = size - delta.getX();
- break;
- case SOUTH:
- amount = mover.size() + delta.getY();
- break;
- case WEST:
- amount = mover.size() + delta.getX();
- break;
- default:
- return null;
- }
- for (int j = 0; j < amount; j++) {
- for (int s = 0; s < mover.size(); s++) {
- switch (direction) {
- case NORTH:
- if (!direction.canMove(mover.getLocation().transform(s, j + mover.size(), 0))) {
- direction = Direction.get((direction.toInteger() + 1) & 3);
- continue main;
- }
- break;
- case EAST:
- if (!direction.canMove(mover.getLocation().transform(j + mover.size(), s, 0))) {
- direction = Direction.get((direction.toInteger() + 1) & 3);
- continue main;
- }
- break;
- case SOUTH:
- if (!direction.canMove(mover.getLocation().transform(s, -(j + 1), 0))) {
- direction = Direction.get((direction.toInteger() + 1) & 3);
- continue main;
- }
- break;
- case WEST:
- if (!direction.canMove(mover.getLocation().transform(-(j + 1), s, 0))) {
- direction = Direction.get((direction.toInteger() + 1) & 3);
- continue main;
- }
- break;
- default:
- return null;
- }
- }
- }
- Location location = mover.getLocation().transform(direction, amount);
- return location;
- }
- return null;
- }
-
- /**
- * Checks if the mover is standing on an invalid position.
- *
- * @param l The location.
- * @return {@code True} if so.
- */
- private boolean isInsideEntity(Location l) {
- if (!(destination instanceof Entity)) {
- return false;
- }
- if (((Entity) destination).getWalkingQueue().isMoving()) {
- return false;
- }
- Location loc = destination.getLocation();
- int size = destination.size();
- return Pathfinder.isStandingIn(l.getX(), l.getY(), mover.size(), mover.size(), loc.getX(), loc.getY(), size, size);
- }
-
- /**
- * Gets the forceRun.
- *
- * @return The forceRun.
- */
- public boolean isForceRun() {
- return forceRun;
- }
-
- /**
- * Sets the forceRun.
- *
- * @param forceRun The forceRun to set.
- */
- public void setForceRun(boolean forceRun) {
- this.forceRun = forceRun;
- }
-
- /**
- * Sets the current destination.
- *
- * @param destination The destination.
- */
- public void setDestination(Node destination) {
- this.destination = destination;
- }
-
- /**
- * Sets the last location.
- *
- * @param last The last location.
- */
- public void setLast(Location last) {
- this.last = last;
- }
-
+
+ private boolean shouldTruncateEntityPath() {
+ return destination instanceof Entity && !hasExplicitInteractionLocation() && !hasQueuedMovement((Entity) destination);
+ }
+
+ private boolean overlapsEntityFootprint(Location source, Entity target) {
+ if (source == null || target == null) {
+ return false;
+ }
+ Location targetLocation = target.getLocation();
+ if (targetLocation == null) {
+ return false;
+ }
+ return Pathfinder.isStandingIn(source.getX(),
+ source.getY(),
+ mover.size(),
+ mover.size(),
+ targetLocation.getX(),
+ targetLocation.getY(),
+ target.size(),
+ target.size());
+ }
+
+ private boolean hasQueuedMovement(Entity entity) {
+ for (Point point : entity.getWalkingQueue().getQueue()) {
+ if (point.getDirection() != null) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private Point[] queuedMovementPoints(WalkingQueue walkingQueue) {
+ ArrayList points = new ArrayList<>();
+ for (Point point : walkingQueue.getQueue()) {
+ if (point.getDirection() != null) {
+ points.add(point);
+ }
+ }
+ return points.toArray(new Point[0]);
+ }
+
+ private Location getClosestBorderToPoint(Point p, int plane) {
+ Vector pathDiff = Vector.betweenLocs(destination.getLocation(), Location.create(p.getX(), p.getY(), plane));
+ Vector predictedCenterPos = destination.getMathematicalCenter().plus(pathDiff);
+ Vector moverCenter = mover.getMathematicalCenter();
+ Vector toMover = new Vector(moverCenter.getX() - predictedCenterPos.getX(), moverCenter.getY() - predictedCenterPos.getY());
+ if (toMover.magnitude() == 0.0) {
+ return predictedCenterPos.toLocation(plane);
+ }
+ double centerDistance = (destination.size() + mover.size()) / 2.0;
+ double moverOriginOffset = (mover.size() - 1) / 2.0;
+ Vector interactCenter = predictedCenterPos.plus(toMover.normalized().times(centerDistance));
+ return interactCenter.minus(new Vector(moverOriginOffset, moverOriginOffset)).toLocation(plane);
+ }
+
+ private Location findBorderLocation() {
+ return findBorderLocation(destination.getLocation());
+ }
+
+ /**
+ * Finds the closest location next to the node.
+ *
+ * @return The location to walk to.
+ */
+ private Location findBorderLocation(Location centerDestLoc) {
+ int size = destination.size();
+ Location centerDest = centerDestLoc.transform(size >> 1, size >> 1, 0);
+ Location center = mover.getLocation().transform(mover.size() >> 1, mover.size() >> 1, 0);
+ Direction direction = Direction.getLogicalDirection(centerDest, center);
+ Location delta = Location.getDelta(centerDestLoc, mover.getLocation());
+ main:
+ for (int i = 0; i < 4; i++) {
+ int amount = 0;
+ switch (direction) {
+ case NORTH:
+ amount = size - delta.getY();
+ break;
+ case EAST:
+ amount = size - delta.getX();
+ break;
+ case SOUTH:
+ amount = mover.size() + delta.getY();
+ break;
+ case WEST:
+ amount = mover.size() + delta.getX();
+ break;
+ default:
+ return null;
+ }
+ for (int j = 0; j < amount; j++) {
+ for (int s = 0; s < mover.size(); s++) {
+ switch (direction) {
+ case NORTH:
+ if (!direction.canMove(mover.getLocation().transform(s, j + mover.size(), 0))) {
+ direction = Direction.get((direction.toInteger() + 1) & 3);
+ continue main;
+ }
+ break;
+ case EAST:
+ if (!direction.canMove(mover.getLocation().transform(j + mover.size(), s, 0))) {
+ direction = Direction.get((direction.toInteger() + 1) & 3);
+ continue main;
+ }
+ break;
+ case SOUTH:
+ if (!direction.canMove(mover.getLocation().transform(s, -(j + 1), 0))) {
+ direction = Direction.get((direction.toInteger() + 1) & 3);
+ continue main;
+ }
+ break;
+ case WEST:
+ if (!direction.canMove(mover.getLocation().transform(-(j + 1), s, 0))) {
+ direction = Direction.get((direction.toInteger() + 1) & 3);
+ continue main;
+ }
+ break;
+ default:
+ return null;
+ }
+ }
+ }
+ Location location = mover.getLocation().transform(direction, amount);
+ return location;
+ }
+ return null;
+ }
+
+ /**
+ * Checks if the mover is standing on an invalid position.
+ *
+ * @param l The location.
+ * @return {@code True} if so.
+ */
+ private boolean isInsideEntity(Location l) {
+ if (l == null) {
+ return false;
+ }
+ if (!(destination instanceof Entity)) {
+ return false;
+ }
+ if (((Entity) destination).getWalkingQueue().isMoving()) {
+ return false;
+ }
+ Location loc = destination.getLocation();
+ if (loc == null) {
+ return false;
+ }
+ int size = destination.size();
+ return Pathfinder.isStandingIn(l.getX(), l.getY(), mover.size(), mover.size(), loc.getX(), loc.getY(), size, size);
+ }
+
+ /**
+ * Gets the forceRun.
+ *
+ * @return The forceRun.
+ */
+ public boolean isForceRun() {
+ return forceRun;
+ }
+
+ /**
+ * Sets the forceRun.
+ *
+ * @param forceRun The forceRun to set.
+ */
+ public void setForceRun(boolean forceRun) {
+ this.forceRun = forceRun;
+ }
+
+ /**
+ * Sets the current destination.
+ *
+ * @param destination The destination.
+ */
+ public void setDestination(Node destination) {
+ this.destination = destination;
+ }
+
+ /**
+ * Sets the last location.
+ *
+ * @param last The last location.
+ */
+ public void setLast(Location last) {
+ this.last = last;
+ }
+
}
diff --git a/Server/src/main/core/game/interaction/ScriptProcessor.kt b/Server/src/main/core/game/interaction/ScriptProcessor.kt
index 2c638f35f..8c09165a1 100644
--- a/Server/src/main/core/game/interaction/ScriptProcessor.kt
+++ b/Server/src/main/core/game/interaction/ScriptProcessor.kt
@@ -253,15 +253,14 @@ class ScriptProcessor(val entity: Entity) {
is Scenery -> {
val basicPath = Pathfinder.find(entity, interactTarget)
val path = basicPath.points.lastOrNull()
- if (basicPath.isMoveNear) {
- target.location
- return
- }
- if (path == null) {
+ if (!basicPath.isSuccessful) {
clearScripts(entity)
return
}
- Location.create(path.x, path.y, entity.location.z)
+ if (basicPath.isMoveNear) {
+ return
+ }
+ path?.let { Location.create(it.x, it.y, entity.location.z) } ?: entity.location
}
is GroundItem -> DestinationFlag.ITEM.getDestination(entity, interactTarget)
else -> target.location
diff --git a/Server/src/main/core/game/node/entity/Entity.java b/Server/src/main/core/game/node/entity/Entity.java
index ffaa8f461..266d9e7a8 100644
--- a/Server/src/main/core/game/node/entity/Entity.java
+++ b/Server/src/main/core/game/node/entity/Entity.java
@@ -280,7 +280,7 @@ public abstract class Entity extends Node {
impactHandler.getImpactQueue().clear();
impactHandler.setDisabledTicks(10);
timers.onEntityDeath();
- removeAttribute("combat-time");
+ clearCombatDeathState(killer);
face(null);
//Check if it's a Loar shade and transform back into the shadow version.
if(this.getId() == 1240 || this.getId() == 1241){
@@ -288,6 +288,37 @@ public abstract class Entity extends Node {
}
}
+ private void clearCombatDeathState(Entity killer) {
+ Object attacker = getAttribute("combat-attacker");
+ Object aggressor = getAttribute("aggressor");
+ properties.getCombatPulse().stop();
+ removeAttribute("combat-time");
+ removeAttribute("combat-attacker");
+ removeAttribute("aggressor");
+ clearCombatReference(killer);
+ if (attacker instanceof Entity) {
+ clearCombatReference((Entity) attacker);
+ }
+ if (aggressor instanceof Entity) {
+ clearCombatReference((Entity) aggressor);
+ }
+ }
+
+ private void clearCombatReference(Entity entity) {
+ if (entity == null) {
+ return;
+ }
+ if (entity.getAttribute("combat-attacker") == this) {
+ entity.removeAttribute("combat-attacker");
+ }
+ if (entity.getAttribute("aggressor") == this) {
+ entity.removeAttribute("aggressor");
+ }
+ if (entity.getProperties().getCombatPulse().getVictim() == this) {
+ entity.getProperties().getCombatPulse().stop();
+ }
+ }
+
/**
* Updates the location of an entity.
* @param last the last location.
diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt
new file mode 100644
index 000000000..8b3093823
--- /dev/null
+++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt
@@ -0,0 +1,1563 @@
+package core.game.node.entity.combat
+
+import core.ServerConstants
+import core.api.face
+import core.api.sendMessage
+import core.api.stopWalk
+import core.game.container.impl.EquipmentContainer
+import core.game.node.Node
+import core.game.node.entity.Entity
+import core.game.node.entity.combat.equipment.RangeWeapon
+import core.game.node.entity.combat.equipment.Weapon
+import core.game.node.entity.combat.equipment.WeaponInterface
+import core.game.node.entity.npc.NPC
+import core.game.node.entity.player.Player
+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.path.Path
+import core.game.world.map.path.Pathfinder
+import core.game.world.map.path.RsmodPathfinder
+import kotlin.math.abs
+import kotlin.math.sqrt
+
+/**
+ * Collects combat movement requests during pulse updates and applies them before walking queues are
+ * ticked.
+ */
+object CombatMovementIntents {
+ private const val RANGED_APPROACH_BATCH_SIZE = 16
+ private const val MAX_PLAYER_COMBAT_PATH_DETOUR = 6.0
+ private const val MAX_DIRECT_COMBAT_PATH_DISTANCE = 32
+
+ private data class Intent(val attacker: Entity, val target: Entity)
+
+ private data class MovementDestination(
+ val node: Node,
+ val pathfinder: Pathfinder,
+ val allowPartialPath: Boolean,
+ ) {
+ val location: Location
+ get() = node.location
+ }
+
+ private data class CandidatePath(val steps: List, val projectedLocation: Location)
+
+ data class ResolveReport(
+ val intents: Int,
+ val candidateCount: Int,
+ val directPathHits: Int,
+ val rsmodRouteCalls: Int,
+ val losCalls: Int,
+ val slowestIntent: SlowIntent?,
+ ) {
+ fun summary(): String {
+ val slowest =
+ slowestIntent?.let {
+ ", slowestIntent=${it.elapsedMicros}us attacker=${it.attacker} target=${it.target} " +
+ "candidates=${it.candidateCount} directPathHits=${it.directPathHits} " +
+ "rsmodRouteCalls=${it.rsmodRouteCalls} losCalls=${it.losCalls}"
+ } ?: ""
+ return "combatMovementStats intents=$intents candidates=$candidateCount " +
+ "directPathHits=$directPathHits rsmodRouteCalls=$rsmodRouteCalls losCalls=$losCalls$slowest"
+ }
+
+ companion object {
+ val EMPTY = ResolveReport(0, 0, 0, 0, 0, null)
+ }
+ }
+
+ data class SlowIntent(
+ val attacker: String,
+ val target: String,
+ val elapsedMicros: Long,
+ val candidateCount: Int,
+ val directPathHits: Int,
+ val rsmodRouteCalls: Int,
+ val losCalls: Int,
+ )
+
+ private class IntentTrace {
+ var candidateCount = 0
+ var directPathHits = 0
+ var rsmodRouteCalls = 0
+ var losCalls = 0
+ }
+
+ private class ResolveStats {
+ var intents = 0
+ var candidateCount = 0
+ var directPathHits = 0
+ var rsmodRouteCalls = 0
+ var losCalls = 0
+ private var slowestNanos = Long.MIN_VALUE
+ private var slowestIntent: SlowIntent? = null
+
+ fun record(intent: Intent, trace: IntentTrace, elapsedNanos: Long) {
+ intents++
+ candidateCount += trace.candidateCount
+ directPathHits += trace.directPathHits
+ rsmodRouteCalls += trace.rsmodRouteCalls
+ losCalls += trace.losCalls
+ if (elapsedNanos > slowestNanos) {
+ slowestNanos = elapsedNanos
+ slowestIntent =
+ SlowIntent(
+ attacker = describe(intent.attacker),
+ target = describe(intent.target),
+ elapsedMicros = elapsedNanos / 1_000,
+ candidateCount = trace.candidateCount,
+ directPathHits = trace.directPathHits,
+ rsmodRouteCalls = trace.rsmodRouteCalls,
+ losCalls = trace.losCalls,
+ )
+ }
+ }
+
+ fun report(): ResolveReport {
+ return ResolveReport(
+ intents = intents,
+ candidateCount = candidateCount,
+ directPathHits = directPathHits,
+ rsmodRouteCalls = rsmodRouteCalls,
+ losCalls = losCalls,
+ slowestIntent = slowestIntent,
+ )
+ }
+
+ private fun describe(entity: Entity): String {
+ return when (entity) {
+ is Player -> "Player(${entity.username}#${entity.index}@${entity.location})"
+ is NPC -> "NPC(${entity.id}#${entity.index}@${entity.location})"
+ else -> "${entity.javaClass.simpleName}(#${entity.index}@${entity.location})"
+ }
+ }
+ }
+
+ private val intents = LinkedHashMap()
+ private val activeMeleeAttackers = LinkedHashMap()
+ private var lastResolveReport = ResolveReport.EMPTY
+
+ @JvmStatic
+ fun request(attacker: Entity, target: Entity) {
+ if (!attacker.isActive || !target.isActive || attacker.locks.isMovementLocked) {
+ return
+ }
+ if (attacker is NPC && attacker.isNeverWalks) {
+ return
+ }
+ trackActiveMelee(attacker, target)
+ intents[attacker] = Intent(attacker, target)
+ }
+
+ @JvmStatic
+ fun trackActiveMelee(attacker: Entity, target: Entity) {
+ if (isActiveMeleeAttacker(attacker, target)) {
+ activeMeleeAttackers[attacker] = target
+ } else {
+ activeMeleeAttackers.remove(attacker)
+ }
+ }
+
+ @JvmStatic
+ fun untrack(attacker: Entity?) {
+ if (attacker == null) {
+ return
+ }
+ intents.remove(attacker)
+ activeMeleeAttackers.remove(attacker)
+ }
+
+ @JvmStatic
+ fun requestActiveMeleePressure() {
+ if (activeMeleeAttackers.isEmpty()) {
+ return
+ }
+ val iterator = activeMeleeAttackers.entries.iterator()
+ while (iterator.hasNext()) {
+ val entry = iterator.next()
+ val attacker = entry.key
+ val target = entry.value
+ if (!isActiveMeleeAttacker(attacker, target)) {
+ intents.remove(attacker)
+ iterator.remove()
+ continue
+ }
+ if (shouldMaintainMeleePressure(attacker, target)) {
+ intents[attacker] = Intent(attacker, target)
+ }
+ }
+ }
+
+ @JvmStatic
+ fun resolve() {
+ lastResolveReport = ResolveReport.EMPTY
+ if (intents.isEmpty()) {
+ return
+ }
+
+ val pending =
+ intents.values.sortedWith(
+ compareBy { it.attacker.index }.thenBy { it.target.index }
+ )
+ intents.clear()
+
+ val reservedTiles = LinkedHashSet()
+ val projectedLocations = HashMap()
+ val stats = ResolveStats()
+ for (intent in pending) {
+ resolve(intent, reservedTiles, projectedLocations, stats)
+ }
+ lastResolveReport = stats.report()
+ }
+
+ @JvmStatic
+ fun clear() {
+ intents.clear()
+ activeMeleeAttackers.clear()
+ }
+
+ @JvmStatic
+ fun pendingCount(): Int {
+ return intents.size
+ }
+
+ @JvmStatic
+ fun lastResolveSummary(): String {
+ return lastResolveReport.summary()
+ }
+
+ @JvmStatic
+ fun shouldMaintainMeleePressure(attacker: Entity, target: Entity): Boolean {
+ if (attacker.locks.isMovementLocked) {
+ return false
+ }
+ val predictedTargetLocation =
+ CombatMovementPlanner.predictedMovementLocation(target) ?: return false
+ if (canAttackFrom(attacker, target, attacker.location, predictedTargetLocation)) {
+ return false
+ }
+ val projectedAttackerLocation =
+ CombatMovementPlanner.predictedMovementLocation(attacker) ?: attacker.location
+ return !canAttackFrom(attacker, target, projectedAttackerLocation, predictedTargetLocation)
+ }
+
+ private fun isActiveMeleeAttacker(attacker: Entity, target: Entity): Boolean {
+ if (
+ !attacker.isActive ||
+ !target.isActive ||
+ attacker.location == null ||
+ target.location == null
+ ) {
+ return false
+ }
+ if (attacker.locks.isMovementLocked || attacker.location.z != target.location.z) {
+ return false
+ }
+ if (attacker is NPC && attacker.isNeverWalks) {
+ return false
+ }
+ val pulse = attacker.properties.combatPulse
+ return pulse.getVictim() === target && pulse.isAttacking && pulse.style == CombatStyle.MELEE
+ }
+
+ private fun resolve(
+ intent: Intent,
+ reservedTiles: MutableSet,
+ projectedLocations: MutableMap,
+ stats: ResolveStats,
+ ) {
+ val trace = IntentTrace()
+ val start = System.nanoTime()
+ try {
+ resolveIntent(intent, reservedTiles, projectedLocations, trace)
+ } finally {
+ stats.record(intent, trace, System.nanoTime() - start)
+ }
+ }
+
+ private fun resolveIntent(
+ intent: Intent,
+ reservedTiles: MutableSet,
+ projectedLocations: MutableMap,
+ trace: IntentTrace,
+ ) {
+ val attacker = intent.attacker
+ val target = intent.target
+ if (!canResolve(attacker, target)) {
+ return
+ }
+
+ val targetLocation = targetLocationFor(attacker, target)
+ val projectedTargetLocation = projectedLocations[target]
+ if (
+ projectedTargetLocation != null &&
+ canAttackFrom(
+ attacker,
+ target,
+ attacker.location,
+ projectedTargetLocation,
+ trace,
+ )
+ ) {
+ stopWalk(attacker)
+ face(attacker, target)
+ projectedLocations[attacker] = attacker.location
+ reserveOccupiedTiles(reservedTiles, attacker, attacker.location)
+ return
+ }
+ if (
+ projectedTargetLocation != targetLocation &&
+ canAttackFrom(
+ attacker,
+ target,
+ attacker.location,
+ targetLocation,
+ trace,
+ )
+ ) {
+ stopWalk(attacker)
+ face(attacker, target)
+ projectedLocations[attacker] = attacker.location
+ reserveOccupiedTiles(reservedTiles, attacker, attacker.location)
+ return
+ }
+
+ val pathfinder = pathfinderFor(attacker)
+ if (shouldUseTargetFootprintRoute(attacker, target, pathfinder)) {
+ val candidatePath =
+ pathToTargetFootprint(attacker, target, targetLocation, pathfinder, trace)
+ if (candidatePath != null) {
+ val attackPath =
+ truncateAtFirstAttackOpportunity(
+ attacker,
+ target,
+ targetLocation,
+ candidatePath,
+ trace,
+ )
+ if (attackPath != null) {
+ if (
+ hasReservedOccupiedTile(
+ reservedTiles,
+ attacker,
+ attackPath.projectedLocation,
+ )
+ ) {
+ return
+ }
+ walkPath(attacker, attackPath)
+ face(attacker, target)
+ projectedLocations[attacker] = attackPath.projectedLocation
+ reserveOccupiedTiles(reservedTiles, attacker, attackPath.projectedLocation)
+ return
+ }
+ }
+ }
+
+ val projectedAttackerLocation =
+ CombatMovementPlanner.predictedMovementLocation(attacker) ?: attacker.location
+ if (
+ projectedAttackerLocation != attacker.location &&
+ canAttackFrom(
+ attacker,
+ target,
+ projectedAttackerLocation,
+ targetLocation,
+ trace,
+ )
+ ) {
+ face(attacker, target)
+ projectedLocations[attacker] = projectedAttackerLocation
+ reserveOccupiedTiles(reservedTiles, attacker, projectedAttackerLocation)
+ return
+ }
+
+ val candidates =
+ movementDestinationsFor(attacker, target, targetLocation, pathfinder, trace)
+
+ val queueStationaryContinuation =
+ attacker.properties.combatPulse.style == CombatStyle.MELEE &&
+ !CombatMovementPlanner.hasMovementStepThisTick(target)
+ var standingOnCandidate = false
+ var blockedByReservation = false
+ for (candidate in candidates) {
+ if (candidate.location == attacker.location) {
+ standingOnCandidate = true
+ }
+ val candidatePath =
+ pathTo(attacker, candidate, trace, queueStationaryContinuation) ?: continue
+ val attackPath =
+ truncateAtFirstAttackOpportunity(
+ attacker,
+ target,
+ targetLocation,
+ candidatePath,
+ trace,
+ ) ?: continue
+ if (hasReservedOccupiedTile(reservedTiles, attacker, attackPath.projectedLocation)) {
+ blockedByReservation = true
+ continue
+ }
+
+ walkPath(attacker, attackPath)
+ face(attacker, target)
+ projectedLocations[attacker] = attackPath.projectedLocation
+ reserveOccupiedTiles(reservedTiles, attacker, attackPath.projectedLocation)
+ return
+ }
+ if (
+ !blockedByReservation &&
+ shouldStopUnreachableCombat(attacker, target, standingOnCandidate, trace)
+ ) {
+ stopUnreachableCombat(attacker)
+ }
+ }
+
+ private fun canResolve(attacker: Entity, target: Entity): Boolean {
+ if (
+ !attacker.isActive ||
+ !target.isActive ||
+ attacker.location == null ||
+ target.location == null
+ ) {
+ return false
+ }
+ if (attacker.location.z != target.location.z || attacker.locks.isMovementLocked) {
+ return false
+ }
+ if (
+ attacker.properties.combatPulse.getVictim() !== target ||
+ !attacker.properties.combatPulse.isAttacking
+ ) {
+ return false
+ }
+ if (CombatMovementPlanner.exceedsCombatChaseDistance(attacker, target)) {
+ if (shouldStopUnreachableCombat(attacker, target)) {
+ stopUnreachableCombat(attacker)
+ } else {
+ attacker.properties.combatPulse.stop()
+ }
+ return false
+ }
+ return attacker !is NPC || !attacker.isNeverWalks
+ }
+
+ private fun canAttackFrom(
+ attacker: Entity,
+ target: Entity,
+ attackerLocation: Location,
+ targetLocation: Location,
+ trace: IntentTrace? = null,
+ ): Boolean {
+ if (attackerLocation.z != targetLocation.z) {
+ return false
+ }
+ if (occupiedTilesOverlap(attacker, attackerLocation, target, targetLocation)) {
+ return false
+ }
+ return when (attacker.properties.combatPulse.style) {
+ CombatStyle.RANGE,
+ CombatStyle.MAGIC ->
+ canAttackFromRange(
+ attacker,
+ target,
+ attackerLocation,
+ targetLocation,
+ trace,
+ )
+
+ else -> canAttackFromMelee(attacker, target, attackerLocation, targetLocation, trace)
+ }
+ }
+
+ private fun movementDestinationsFor(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ pathfinder: Pathfinder,
+ trace: IntentTrace,
+ ): Sequence {
+ if (attacker is NPC && pathfinder === Pathfinder.DUMB) {
+ val destinations = ArrayList(4)
+ if (occupiedTilesOverlap(attacker, target)) {
+ for (location in
+ CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation)) {
+ destinations.add(
+ MovementDestination(location, pathfinder, allowPartialPath = true)
+ )
+ }
+ } 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.asSequence()
+ }
+
+ val attackTiles = attackTilesFor(attacker, target, targetLocation, trace)
+ if (attacker is Player) {
+ val targetFallback =
+ if (
+ !occupiedTilesOverlap(attacker, target) &&
+ shouldAllowPartialTargetPath(
+ attacker,
+ target,
+ targetLocation,
+ )
+ ) {
+ MovementDestination(target, pathfinder, allowPartialPath = true)
+ } else {
+ null
+ }
+ return sequence {
+ yieldAll(
+ playerAttackRangeDestinations(
+ attacker,
+ target,
+ targetLocation,
+ pathfinder,
+ trace,
+ )
+ )
+ for (location in attackTiles) {
+ trace.candidateCount++
+ yield(MovementDestination(location, pathfinder, allowPartialPath = false))
+ }
+ if (targetFallback != null) {
+ trace.candidateCount++
+ yield(targetFallback)
+ }
+ }
+ }
+
+ val destinations = ArrayList(attackTiles.size)
+ for (location in attackTiles) {
+ destinations.add(MovementDestination(location, pathfinder, allowPartialPath = false))
+ }
+ trace.candidateCount += destinations.size
+ return destinations.asSequence()
+ }
+
+ private fun attackTilesFor(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ trace: IntentTrace,
+ ): List {
+ if (attacker.properties.combatPulse.style != CombatStyle.MELEE) {
+ return CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation)
+ }
+ val borderTiles = CombatMovementPlanner.borderTiles(target, targetLocation, attacker.size())
+ val walkable = borderTiles.filter { RegionManager.isTeleportPermitted(it) }
+ val candidates = walkable.ifEmpty { borderTiles }
+ val attackable = candidates.filter {
+ canAttackFrom(attacker, target, it, targetLocation, trace)
+ }
+ if (attackable.isEmpty() && !CombatMovementPlanner.hasMovementStepThisTick(target)) {
+ return emptyList()
+ }
+ return attackable
+ .ifEmpty { candidates }
+ .sortedWith(
+ compareBy {
+ distanceSquared(
+ it,
+ attacker.location,
+ )
+ }
+ .thenBy { distanceSquaredToClosestOccupiedTile(target, targetLocation, it) }
+ .thenBy { it.x }
+ .thenBy { it.y }
+ )
+ }
+
+ private fun shouldAllowPartialTargetPath(
+ attacker: Player,
+ target: Entity,
+ targetLocation: Location,
+ ): Boolean {
+ val range = playerAttackRange(attacker)
+ if (range <= CombatReach.meleeDistance(attacker)) {
+ return true
+ }
+ if (CombatMovementPlanner.hasMovementStepThisTick(target)) {
+ return true
+ }
+ return distanceSquaredToClosestOccupiedTile(target, targetLocation, attacker.location) >
+ range * range
+ }
+
+ private fun playerAttackRangeDestinations(
+ attacker: Player,
+ target: Entity,
+ targetLocation: Location,
+ pathfinder: Pathfinder,
+ trace: IntentTrace,
+ ): Sequence {
+ val range = playerAttackRange(attacker)
+ if (range <= CombatReach.meleeDistance(attacker)) {
+ return emptySequence()
+ }
+ val attackTiles = attackRangeTiles(attacker, target, targetLocation, range, trace)
+ return attackTiles.map { tile ->
+ trace.candidateCount++
+ MovementDestination(tile, pathfinder, allowPartialPath = false)
+ }
+ }
+
+ private fun playerAttackRange(attacker: Player): Int {
+ return when (attacker.properties.combatPulse.style) {
+ CombatStyle.MAGIC -> 10
+ CombatStyle.RANGE -> playerRangedAttackRange(attacker)
+ else -> CombatReach.meleeDistance(attacker)
+ }
+ }
+
+ private fun playerRangedAttackRange(attacker: Player): Int {
+ var distance = 7
+ val weaponInterface =
+ attacker.getExtension(WeaponInterface::class.java) as? WeaponInterface
+ if (weaponInterface?.weaponInterface?.interfaceId == 91) {
+ distance -= 2
+ }
+ if (attacker.properties.attackStyle.style == WeaponInterface.STYLE_LONG_RANGE) {
+ distance += 2
+ }
+ val rangeWeapon =
+ RangeWeapon.get(attacker.equipment.getNew(EquipmentContainer.SLOT_WEAPON).id)
+ if (
+ rangeWeapon != null &&
+ (rangeWeapon.weaponType == Weapon.WeaponType.DOUBLE_SHOT ||
+ rangeWeapon.weaponType == Weapon.WeaponType.DEGRADING)
+ ) {
+ distance = 10
+ }
+ return distance
+ }
+
+ private fun attackRangeTiles(
+ attacker: Player,
+ target: Entity,
+ targetLocation: Location,
+ range: Int,
+ trace: IntentTrace,
+ ): Sequence {
+ val tiles = ArrayList()
+ val minX = targetLocation.x - range
+ val maxX = targetLocation.x + target.size() - 1 + range
+ val minY = targetLocation.y - range
+ val maxY = targetLocation.y + target.size() - 1 + range
+ for (x in minX..maxX) {
+ for (y in minY..maxY) {
+ val tile = Location.create(x, y, targetLocation.z)
+ if (
+ distanceSquaredToClosestOccupiedTile(target, targetLocation, tile) >
+ range * range
+ ) {
+ continue
+ }
+ if (RegionManager.isTeleportPermitted(tile)) {
+ tiles.add(tile)
+ }
+ }
+ }
+ tiles.sortWith(
+ compareBy {
+ distanceSquared(
+ it,
+ attacker.location,
+ )
+ }
+ .thenBy { distanceSquaredToClosestOccupiedTile(target, targetLocation, it) }
+ .thenBy { it.x }
+ .thenBy { it.y }
+ )
+ return sequence {
+ var nextTile = 0
+ while (nextTile < tiles.size) {
+ val batch = ArrayList(RANGED_APPROACH_BATCH_SIZE)
+ RsmodPathfinder.loadLineOfSightWindow(targetLocation)
+ while (nextTile < tiles.size && batch.size < RANGED_APPROACH_BATCH_SIZE) {
+ val tile = tiles[nextTile++]
+ if (
+ hasProjectileLineOfSight(
+ tile,
+ attacker.size(),
+ target,
+ targetLocation,
+ loadWindow = false,
+ trace = trace,
+ )
+ ) {
+ batch.add(tile)
+ }
+ }
+ yieldAll(batch)
+ }
+ }
+ }
+
+ private fun canAttackFromMelee(
+ attacker: Entity,
+ target: Entity,
+ attackerLocation: Location,
+ targetLocation: Location,
+ trace: IntentTrace? = null,
+ ): Boolean {
+ val distance = CombatReach.meleeDistance(attacker)
+ if (
+ distance == 1 && !isAdjacentToTarget(attacker, attackerLocation, target, targetLocation)
+ ) {
+ return false
+ }
+ if (
+ distance > 1 &&
+ distanceSquaredToClosestOccupiedTile(
+ target,
+ targetLocation,
+ attackerLocation,
+ ) > distance * distance
+ ) {
+ return false
+ }
+ if (CombatReach.hasExtendedMeleeReach(attacker)) {
+ return hasProjectileLineOfSight(
+ attackerLocation,
+ attacker.size(),
+ target,
+ targetLocation,
+ trace = trace,
+ )
+ }
+ return CombatReach.hasMeleeReach(
+ attackerLocation,
+ attacker.size(),
+ targetLocation,
+ target.size(),
+ )
+ }
+
+ private fun canAttackFromRange(
+ attacker: Entity,
+ target: Entity,
+ attackerLocation: Location,
+ targetLocation: Location,
+ trace: IntentTrace? = null,
+ ): Boolean {
+ val inRange =
+ if (attacker is Player) {
+ val range = playerAttackRange(attacker)
+ distanceSquaredToClosestOccupiedTile(
+ target,
+ targetLocation,
+ attackerLocation,
+ ) <= range * range
+ } else {
+ val range =
+ CombatReach.combatDistance(
+ attacker,
+ target,
+ if (attacker.properties.combatPulse.style == CombatStyle.MAGIC) 10 else 7,
+ )
+ val attackerOffset = attacker.size() shr 1
+ val targetOffset = target.size() shr 1
+ val attackerCenter = attackerLocation.transform(attackerOffset, attackerOffset, 0)
+ val targetCenter = targetLocation.transform(targetOffset, targetOffset, 0)
+ targetCenter.withinDistance(attackerCenter, range)
+ }
+ return inRange &&
+ hasProjectileLineOfSight(
+ attackerLocation,
+ attacker.size(),
+ target,
+ targetLocation,
+ trace = trace,
+ )
+ }
+
+ private fun isAdjacentToTarget(
+ attacker: Entity,
+ attackerLocation: Location,
+ target: Entity,
+ targetLocation: Location,
+ ): Boolean {
+ for (i in 0 until attacker.size()) {
+ if (
+ Pathfinder.isStandingIn(
+ attackerLocation.x - 1,
+ attackerLocation.y + i,
+ 1,
+ 1,
+ targetLocation.x,
+ targetLocation.y,
+ target.size(),
+ target.size(),
+ )
+ ) {
+ return true
+ }
+ if (
+ Pathfinder.isStandingIn(
+ attackerLocation.x + attacker.size(),
+ attackerLocation.y + i,
+ 1,
+ 1,
+ targetLocation.x,
+ targetLocation.y,
+ target.size(),
+ target.size(),
+ )
+ ) {
+ return true
+ }
+ if (
+ Pathfinder.isStandingIn(
+ attackerLocation.x + i,
+ attackerLocation.y - 1,
+ 1,
+ 1,
+ targetLocation.x,
+ targetLocation.y,
+ target.size(),
+ target.size(),
+ )
+ ) {
+ return true
+ }
+ if (
+ Pathfinder.isStandingIn(
+ attackerLocation.x + i,
+ attackerLocation.y + attacker.size(),
+ 1,
+ 1,
+ targetLocation.x,
+ targetLocation.y,
+ target.size(),
+ target.size(),
+ )
+ ) {
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun hasProjectileLineOfSight(
+ attackerLocation: Location,
+ attackerSize: Int,
+ target: Entity,
+ targetLocation: Location,
+ checkClose: Boolean = false,
+ loadWindow: Boolean = true,
+ trace: IntentTrace? = null,
+ ): Boolean {
+ if (trace != null) {
+ trace.losCalls++
+ }
+ val maxRaySteps = if (checkClose) 1 else Int.MAX_VALUE
+ if (!loadWindow) {
+ return RsmodPathfinder.hasLineOfSightBetweenLoaded(
+ attackerLocation,
+ attackerSize,
+ targetLocation,
+ target.size(),
+ maxRaySteps = maxRaySteps,
+ )
+ }
+ return RsmodPathfinder.hasLineOfSightBetween(
+ attackerLocation,
+ attackerSize,
+ targetLocation,
+ target.size(),
+ maxRaySteps = maxRaySteps,
+ )
+ }
+
+ private fun distanceSquared(first: Location, second: Location): Int {
+ val dx = first.x - second.x
+ val dy = first.y - second.y
+ return dx * dx + dy * dy
+ }
+
+ private fun distanceSquaredToClosestOccupiedTile(
+ entity: Entity,
+ location: Location,
+ from: Location,
+ ): Int {
+ val closestX = from.x.coerceIn(location.x, location.x + entity.size() - 1)
+ val closestY = from.y.coerceIn(location.y, location.y + entity.size() - 1)
+ val dx = from.x - closestX
+ val dy = from.y - closestY
+ 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
+ }
+ return attacker !is NPC || pathfinder !== Pathfinder.DUMB
+ }
+
+ private fun pathToTargetFootprint(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ pathfinder: Pathfinder,
+ trace: IntentTrace,
+ ): CandidatePath? {
+ val targetMoving = CombatMovementPlanner.hasMovementStepThisTick(target)
+ val movingTargetPath =
+ if (targetMoving) directPathTowardMovingTarget(attacker, target, targetLocation)
+ else null
+ if (movingTargetPath != null) {
+ trace.directPathHits++
+ return movingTargetPath
+ }
+ for (directDestination in preferredMeleeDestinations(attacker, target, targetLocation)) {
+ // Walking to a side the target can't be attacked from (e.g. behind a fence) would
+ // tug the attacker back and forth against the candidate routing below.
+ if (!canAttackFrom(attacker, target, directDestination, targetLocation, trace)) {
+ continue
+ }
+ val directPath = directPathTo(attacker, directDestination)
+ if (directPath != null) {
+ trace.directPathHits++
+ return directPath
+ }
+ }
+ if (!targetMoving) {
+ return null
+ }
+ if (attacker is NPC && pathfinder === Pathfinder.DUMB) {
+ return null
+ }
+ if (
+ pathfinder === Pathfinder.SMART &&
+ !RsmodPathfinder.canAttempt(attacker.location, targetLocation)
+ ) {
+ return null
+ }
+
+ trace.rsmodRouteCalls++
+ val clipMaskSupplier =
+ if (attacker is NPC) {
+ attacker.behavior?.getClippingSupplier(attacker)
+ } else {
+ null
+ }
+ val path =
+ pathfinder.find(
+ attacker.location,
+ attacker.size(),
+ targetLocation,
+ target.size(),
+ target.size(),
+ 0,
+ -1,
+ 0,
+ true,
+ clipMaskSupplier,
+ )
+ if (!path.isSuccessful || path.points.isEmpty()) {
+ return null
+ }
+ if (
+ attacker is Player &&
+ !path.isMoveNear &&
+ isExcessiveCombatDetourToTarget(
+ attacker,
+ target,
+ targetLocation,
+ path,
+ )
+ ) {
+ return null
+ }
+ val steps = immediateMovementSteps(attacker, path)
+ if (
+ attacker is Player &&
+ path.isMoveNear &&
+ !partialPathMovesCloserToTarget(
+ attacker,
+ target,
+ targetLocation,
+ steps,
+ )
+ ) {
+ return null
+ }
+ if (
+ attacker is Player &&
+ steps.any {
+ !RegionManager.isTeleportPermitted(
+ Location.create(
+ it.x,
+ it.y,
+ attacker.location.z,
+ )
+ )
+ }
+ ) {
+ return null
+ }
+ val projected =
+ steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) }
+ ?: return null
+ return CandidatePath(steps, projected)
+ }
+
+ private fun directPathTowardMovingTarget(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ ): CandidatePath? {
+ if (!CombatMovementPlanner.hasMovementStepThisTick(target)) {
+ return null
+ }
+ if (attacker.size() != 1 || attacker.location.z != targetLocation.z) {
+ return null
+ }
+ val maxSteps = movementStepsFor(attacker)
+ val steps = ArrayList(maxSteps)
+ var current = attacker.location
+ while (current != targetLocation && steps.size < maxSteps) {
+ val direction = Direction.getDirection(current, targetLocation) ?: return null
+ if (
+ !direction.canMoveFrom(
+ current.z,
+ current.x,
+ current.y,
+ RegionManager::getClippingFlag,
+ )
+ ) {
+ return null
+ }
+ val next = current.transform(direction)
+ if (!RegionManager.isTeleportPermitted(next)) {
+ return null
+ }
+ steps.add(Point(next.x, next.y, direction, direction.stepX, direction.stepY))
+ current = next
+ }
+ while (steps.isNotEmpty()) {
+ val projected = steps.last().let { Location.create(it.x, it.y, attacker.location.z) }
+ if (!occupiedTilesOverlap(attacker, projected, target, targetLocation)) {
+ return CandidatePath(steps, projected)
+ }
+ steps.removeAt(steps.lastIndex)
+ }
+ return null
+ }
+
+ private fun preferredMeleeDestinations(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ ): List {
+ val directions = ArrayList(4)
+ val attackerCenter = attacker.centerLocation
+ val targetCenterX = targetLocation.x + (target.size() shr 1)
+ val targetCenterY = targetLocation.y + (target.size() shr 1)
+ val dx = attackerCenter.x - targetCenterX
+ val dy = attackerCenter.y - targetCenterY
+
+ if (abs(dx) >= abs(dy)) {
+ if (dx < 0) {
+ addDirection(directions, Direction.WEST)
+ } else if (dx > 0) {
+ addDirection(directions, Direction.EAST)
+ }
+ }
+ if (abs(dy) >= abs(dx)) {
+ if (dy < 0) {
+ addDirection(directions, Direction.SOUTH)
+ } else if (dy > 0) {
+ addDirection(directions, Direction.NORTH)
+ }
+ }
+ if (attackerCenter.x < targetCenterX) {
+ addDirection(directions, Direction.WEST)
+ } else if (attackerCenter.x > targetCenterX) {
+ addDirection(directions, Direction.EAST)
+ }
+ if (attackerCenter.y < targetCenterY) {
+ addDirection(directions, Direction.SOUTH)
+ } else if (attackerCenter.y > targetCenterY) {
+ addDirection(directions, Direction.NORTH)
+ }
+
+ return directions.map { meleeDestination(attacker, target, targetLocation, it) }
+ }
+
+ private fun meleeDestination(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ direction: Direction,
+ ): 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 addDirection(directions: MutableList, direction: Direction) {
+ if (!directions.contains(direction)) {
+ directions.add(direction)
+ }
+ }
+
+ private fun truncateAtFirstAttackOpportunity(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ path: CandidatePath,
+ trace: IntentTrace,
+ ): CandidatePath? {
+ val steps = ArrayList(path.steps.size)
+ for (step in path.steps) {
+ val location = Location.create(step.x, step.y, attacker.location.z)
+ if (occupiedTilesOverlap(attacker, location, target, targetLocation)) {
+ return null
+ }
+ steps.add(step)
+ if (canAttackFrom(attacker, target, location, targetLocation, trace)) {
+ return CandidatePath(steps, location)
+ }
+ }
+ return path
+ }
+
+ private fun pathTo(
+ attacker: Entity,
+ destination: MovementDestination,
+ trace: IntentTrace,
+ queueContinuation: Boolean = false,
+ ): CandidatePath? {
+ if (attacker.location == destination.location) {
+ return null
+ }
+ val stepLimit =
+ movementStepLimit(attacker, queueContinuation && !destination.allowPartialPath)
+ val directPath = directPathTo(attacker, destination, stepLimit)
+ if (directPath != null) {
+ trace.directPathHits++
+ return directPath
+ }
+ if (
+ destination.pathfinder === Pathfinder.SMART &&
+ !RsmodPathfinder.canAttempt(
+ attacker.location,
+ destination.location,
+ )
+ ) {
+ return null
+ }
+
+ trace.rsmodRouteCalls++
+ val path =
+ Pathfinder.find(
+ attacker,
+ destination.node,
+ destination.allowPartialPath,
+ destination.pathfinder,
+ )
+ if (
+ !path.reaches(destination.location) &&
+ (!destination.allowPartialPath || path.points.isEmpty())
+ ) {
+ return null
+ }
+ if (
+ attacker is Player &&
+ !destination.allowPartialPath &&
+ isExcessiveCombatDetour(
+ attacker,
+ destination,
+ path,
+ )
+ ) {
+ return null
+ }
+ val steps = immediateMovementSteps(attacker, path, stepLimit)
+ if (
+ attacker is Player &&
+ destination.allowPartialPath &&
+ !partialPathMovesCloser(
+ attacker,
+ destination,
+ steps,
+ )
+ ) {
+ return null
+ }
+ if (
+ attacker is Player &&
+ steps.any {
+ !RegionManager.isTeleportPermitted(
+ Location.create(
+ it.x,
+ it.y,
+ attacker.location.z,
+ )
+ )
+ }
+ ) {
+ return null
+ }
+ val projected =
+ steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) }
+ ?: return null
+ return CandidatePath(steps, projected)
+ }
+
+ private fun directPathTo(
+ attacker: Entity,
+ destination: MovementDestination,
+ maxSteps: Int = movementStepsFor(attacker),
+ ): CandidatePath? {
+ if (
+ attacker.size() != 1 ||
+ destination.node !is Location ||
+ attacker.location.z != destination.location.z
+ ) {
+ return null
+ }
+ return directPathTo(attacker, destination.location, maxSteps)
+ }
+
+ private fun directPathTo(
+ attacker: Entity,
+ destination: Location,
+ maxSteps: Int = movementStepsFor(attacker),
+ ): CandidatePath? {
+ if (attacker.size() != 1 || attacker.location.z != destination.z) {
+ return null
+ }
+ val steps = ArrayList(maxSteps)
+ var current = attacker.location
+ var distance = 0
+ while (current != destination) {
+ if (++distance > MAX_DIRECT_COMBAT_PATH_DISTANCE) {
+ return null
+ }
+ val direction = Direction.getDirection(current, destination) ?: return null
+ if (
+ !direction.canMoveFrom(
+ current.z,
+ current.x,
+ current.y,
+ RegionManager::getClippingFlag,
+ )
+ ) {
+ return null
+ }
+ val next = current.transform(direction)
+ if (!RegionManager.isTeleportPermitted(next)) {
+ return null
+ }
+ if (steps.size < maxSteps) {
+ steps.add(Point(next.x, next.y, direction, direction.stepX, direction.stepY))
+ }
+ current = next
+ }
+ if (steps.isEmpty()) {
+ return null
+ }
+ val projected = steps.last().let { Location.create(it.x, it.y, attacker.location.z) }
+ return CandidatePath(steps, projected)
+ }
+
+ private fun isExcessiveCombatDetourToTarget(
+ attacker: Player,
+ target: Entity,
+ targetLocation: Location,
+ path: Path,
+ ): Boolean {
+ val pathLength = (path.points.size - 1).coerceAtLeast(0)
+ val directDistance =
+ sqrt(
+ distanceSquaredToClosestOccupiedTile(target, targetLocation, attacker.location)
+ .toDouble()
+ )
+ return pathLength > directDistance + MAX_PLAYER_COMBAT_PATH_DETOUR
+ }
+
+ private fun partialPathMovesCloserToTarget(
+ attacker: Player,
+ target: Entity,
+ targetLocation: Location,
+ steps: List,
+ ): Boolean {
+ val projected =
+ steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) }
+ ?: return false
+ return distanceSquaredToClosestOccupiedTile(
+ target,
+ targetLocation,
+ projected,
+ ) < distanceSquaredToClosestOccupiedTile(target, targetLocation, attacker.location)
+ }
+
+ private fun isExcessiveCombatDetour(
+ attacker: Player,
+ destination: MovementDestination,
+ path: Path,
+ ): Boolean {
+ val pathLength = (path.points.size - 1).coerceAtLeast(0)
+ val directDistance = attacker.location.getDistance(destination.location)
+ return pathLength > directDistance + MAX_PLAYER_COMBAT_PATH_DETOUR
+ }
+
+ private fun partialPathMovesCloser(
+ attacker: Player,
+ destination: MovementDestination,
+ steps: List,
+ ): Boolean {
+ val projected =
+ steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) }
+ ?: return false
+ return projected.getDistance(destination.location) <
+ attacker.location.getDistance(destination.location)
+ }
+
+ private fun Path.reaches(destination: Location): Boolean {
+ if (!isSuccessful || isMoveNear) {
+ return false
+ }
+ val terminal = points.peekLast() ?: return false
+ return terminal.x == destination.x && terminal.y == destination.y
+ }
+
+ private fun immediateMovementSteps(
+ attacker: Entity,
+ path: Path,
+ maxSteps: Int = movementStepsFor(attacker),
+ ): List {
+ val steps = ArrayList(maxSteps)
+ for (point in path.points) {
+ if (point.x == attacker.location.x && point.y == attacker.location.y) {
+ continue
+ }
+ steps.add(point)
+ if (steps.size >= maxSteps) {
+ break
+ }
+ }
+ return steps
+ }
+
+ private fun walkPath(attacker: Entity, path: CandidatePath) {
+ if (attacker.locks.isMovementLocked) {
+ return
+ }
+ 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 targetLocationFor(attacker: Entity, target: Entity): Location {
+ return CombatMovementPlanner.predictedTargetLocation(target, movementStepsFor(attacker))
+ }
+
+ private fun movementStepsFor(attacker: Entity): Int {
+ return if (canRun(attacker)) {
+ 2
+ } else {
+ 1
+ }
+ }
+
+ private fun movementStepLimit(attacker: Entity, queueContinuation: Boolean): Int {
+ return movementStepsFor(attacker) + if (queueContinuation) 1 else 0
+ }
+
+ private fun canRun(attacker: Entity): Boolean {
+ return attacker is Player &&
+ attacker.walkingQueue.isRunningBoth &&
+ attacker.settings.runEnergy >= 1.0
+ }
+
+ private fun shouldStopUnreachableCombat(
+ attacker: Entity,
+ target: Entity,
+ exhaustedLocalApproach: Boolean = false,
+ trace: IntentTrace? = null,
+ ): Boolean {
+ if (attacker !is Player) {
+ return false
+ }
+ if (exhaustedLocalApproach || !CombatMovementPlanner.hasMovementStepThisTick(target)) {
+ return true
+ }
+ if (attacker.properties.combatPulse.style != CombatStyle.MELEE) {
+ return false
+ }
+ return !hasMeleeRouteToTarget(attacker, target, trace)
+ }
+
+ /**
+ * A moving target should only keep a stalled melee chase alive while the static map still
+ * allows walking adjacent to it; an alternative (partial) route means the rest of the approach
+ * is permanently blocked (e.g. an NPC swimming in water), so waiting for the target to stand
+ * still before rejecting would chase it forever.
+ */
+ private fun hasMeleeRouteToTarget(
+ attacker: Player,
+ target: Entity,
+ trace: IntentTrace?,
+ ): Boolean {
+ val targetTile = target.getClosestOccupiedTile(attacker.location)
+ if (attacker.location.getDistance(targetTile) > ServerConstants.MAX_PATHFIND_DISTANCE) {
+ return true
+ }
+ if (trace != null) {
+ trace.rsmodRouteCalls++
+ }
+ val path = Pathfinder.find(attacker, target, true, Pathfinder.SMART)
+ return path.isSuccessful && !path.isMoveNear
+ }
+
+ @JvmStatic
+ fun stopUnreachableCombat(attacker: Entity) {
+ attacker.properties.combatPulse.stop()
+ stopWalk(attacker)
+ if (attacker is Player) {
+ sendMessage(attacker, "I can't reach that!")
+ }
+ }
+
+ private fun hasReservedOccupiedTile(
+ reservedTiles: Set,
+ entity: Entity,
+ location: Location,
+ ): Boolean {
+ if (entity.size() == 1) {
+ return location in reservedTiles
+ }
+ for (x in 0 until entity.size()) {
+ for (y in 0 until entity.size()) {
+ if (location.transform(x, y, 0) in reservedTiles) {
+ return true
+ }
+ }
+ }
+ return false
+ }
+
+ private fun reserveOccupiedTiles(
+ reservedTiles: MutableSet,
+ entity: Entity,
+ location: Location,
+ ) {
+ if (entity.size() == 1) {
+ reservedTiles.add(location)
+ return
+ }
+ for (x in 0 until entity.size()) {
+ for (y in 0 until entity.size()) {
+ reservedTiles.add(location.transform(x, y, 0))
+ }
+ }
+ }
+
+ private fun occupiedTilesOverlap(first: Entity, second: Entity): Boolean {
+ return occupiedTilesOverlap(first, first.location, second, second.location)
+ }
+
+ private fun occupiedTilesOverlap(
+ first: Entity,
+ firstLocation: Location,
+ second: Entity,
+ secondLocation: Location,
+ ): Boolean {
+ return firstLocation.x < secondLocation.x + second.size() &&
+ firstLocation.x + first.size() > secondLocation.x &&
+ firstLocation.y < secondLocation.y + second.size() &&
+ firstLocation.y + first.size() > secondLocation.y
+ }
+
+ private fun pathfinderFor(attacker: Entity): Pathfinder {
+ return when (attacker) {
+ is Player -> Pathfinder.SMART
+ is NPC -> attacker.behavior.getPathfinderOverride(attacker) ?: Pathfinder.DUMB
+ else -> Pathfinder.DUMB
+ }
+ }
+}
diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt
new file mode 100644
index 000000000..a29b03438
--- /dev/null
+++ b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt
@@ -0,0 +1,240 @@
+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.Direction
+import core.game.world.map.Location
+import core.game.world.map.Point
+import core.game.world.map.RegionManager
+import core.game.world.map.path.Pathfinder
+
+/** Plans combat-specific chase targets without mutating walking queues. */
+object CombatMovementPlanner {
+ data class Plan(
+ val targetLocation: Location,
+ val attackTile: Location?,
+ )
+
+ @JvmStatic
+ fun plan(attacker: Entity, target: Entity): Plan {
+ val targetLocation = predictedTargetLocation(target)
+ return Plan(
+ targetLocation = targetLocation,
+ attackTile = chooseTargetBorderTile(attacker, target, targetLocation),
+ )
+ }
+
+ @JvmStatic
+ fun exceedsCombatChaseDistance(attacker: Entity, target: Entity): Boolean {
+ if (attacker.location.z != target.location.z) {
+ return true
+ }
+ val targetTile = target.getClosestOccupiedTile(attacker.location)
+ return attacker.location.getDistance(targetTile) >
+ ServerConstants.MAX_PATHFIND_DISTANCE * 2.0
+ }
+
+ @JvmStatic
+ fun predictedTargetLocation(target: Entity): Location {
+ return predictedTargetLocation(target, 2)
+ }
+
+ @JvmStatic
+ fun predictedTargetLocation(target: Entity, maxSteps: Int): Location {
+ return predictedMovementLocation(target, maxSteps) ?: target.location
+ }
+
+ @JvmStatic
+ fun predictTargetLocations(target: Entity): List {
+ return predictTargetLocations(target, movementStepsThisTick(target))
+ }
+
+ @JvmStatic
+ fun predictTargetLocations(target: Entity, maxSteps: Int): List {
+ if (maxSteps <= 0) {
+ return emptyList()
+ }
+ var first: Point? = null
+ var second: Point? = null
+ for (point in target.walkingQueue.queue) {
+ if (point.direction == null) {
+ continue
+ }
+ if (first == null) {
+ first = point
+ } else {
+ second = point
+ break
+ }
+ }
+ val firstPoint = first ?: return emptyList()
+ val steps = movementStepsThisTick(target, firstPoint, second).coerceAtMost(maxSteps)
+ val predicted = ArrayList(steps)
+ predicted.add(Location.create(firstPoint.x, firstPoint.y, target.location.z))
+ if (steps > 1 && second != null) {
+ predicted.add(Location.create(second.x, second.y, target.location.z))
+ }
+ return predicted
+ }
+
+ @JvmStatic
+ fun movementStepsThisTick(target: Entity): Int {
+ var first: Point? = null
+ var second: Point? = null
+ for (point in target.walkingQueue.queue) {
+ if (point.direction == null) {
+ continue
+ }
+ if (first == null) {
+ first = point
+ } else {
+ second = point
+ break
+ }
+ }
+ return movementStepsThisTick(target, first ?: return 0, second)
+ }
+
+ @JvmStatic
+ fun hasMovementStepThisTick(target: Entity): Boolean {
+ return firstMovementPoint(target) != null
+ }
+
+ @JvmStatic
+ fun predictedMovementLocation(target: Entity): Location? {
+ return predictedMovementLocation(target, 2)
+ }
+
+ @JvmStatic
+ fun chooseTargetBorderTile(attacker: Entity, target: Entity): Location? {
+ return chooseTargetBorderTile(attacker, target, predictedTargetLocation(target))
+ }
+
+ @JvmStatic
+ fun chooseTargetBorderTile(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ ): Location? {
+ return candidateAttackTiles(attacker, target, targetLocation).firstOrNull()
+ }
+
+ @JvmStatic
+ fun candidateAttackTiles(
+ attacker: Entity,
+ target: Entity,
+ targetLocation: Location,
+ ): List {
+ val candidates = borderTiles(target, targetLocation, attacker.size())
+ val walkable = candidates.filter { RegionManager.isTeleportPermitted(it) }
+ val attackable = walkable.filter { canInteractFrom(attacker, it, target, targetLocation) }
+ return (attackable.ifEmpty { walkable.ifEmpty { candidates } }).sortedWith(
+ compareBy {
+ it.getDistance(attacker.location)
+ }
+ .thenBy { it.x }
+ .thenBy { it.y }
+ )
+ }
+
+ private fun canInteractFrom(
+ attacker: Entity,
+ location: Location,
+ target: Entity,
+ targetLocation: Location,
+ ): Boolean {
+ if (attacker.size() == 1 && target.size() == 1) {
+ val direction = Direction.getDirection(location, targetLocation) ?: return false
+ return direction.canMoveFrom(
+ location.z,
+ location.x,
+ location.y,
+ RegionManager::getClippingFlag,
+ )
+ }
+ return Pathfinder.canInteract(
+ location.x,
+ location.y,
+ attacker.size(),
+ targetLocation.x,
+ targetLocation.y,
+ target.size(),
+ target.size(),
+ 0,
+ targetLocation.z,
+ ) { z, x, y ->
+ RegionManager.getClippingFlag(z, x, y)
+ }
+ }
+
+ @JvmStatic
+ fun borderTiles(target: Entity, targetLocation: Location, attackerSize: Int): List {
+ val targetSize = target.size()
+ val border = LinkedHashSet()
+ val plane = targetLocation.z
+ val minOffset = -attackerSize + 1
+ val maxOffset = targetSize - 1
+
+ for (offset in minOffset..maxOffset) {
+ border.add(
+ Location.create(targetLocation.x - attackerSize, targetLocation.y + offset, plane)
+ )
+ border.add(
+ Location.create(targetLocation.x + targetSize, targetLocation.y + offset, plane)
+ )
+ border.add(
+ Location.create(targetLocation.x + offset, targetLocation.y - attackerSize, plane)
+ )
+ border.add(
+ Location.create(targetLocation.x + offset, targetLocation.y + targetSize, plane)
+ )
+ }
+
+ return border.toList()
+ }
+
+ @JvmStatic
+ fun predictedMovementLocation(target: Entity, maxSteps: Int): Location? {
+ if (maxSteps <= 0) {
+ return null
+ }
+ var first: Point? = null
+ var second: Point? = null
+ for (point in target.walkingQueue.queue) {
+ if (point.direction == null) {
+ continue
+ }
+ if (first == null) {
+ first = point
+ } else {
+ second = point
+ break
+ }
+ }
+ val firstPoint = first ?: return null
+ val steps = movementStepsThisTick(target, firstPoint, second).coerceAtMost(maxSteps)
+ val point = if (steps > 1 && second != null) second else firstPoint
+ return Location.create(point.x, point.y, target.location.z)
+ }
+
+ private fun firstMovementPoint(target: Entity): Point? {
+ for (point in target.walkingQueue.queue) {
+ if (point.direction != null) {
+ return point
+ }
+ }
+ return null
+ }
+
+ private fun movementStepsThisTick(target: Entity, first: Point, second: Point?): Int {
+ return if (canMoveTwoStepsThisTick(target, first, second)) 2 else 1
+ }
+
+ private fun canMoveTwoStepsThisTick(target: Entity, first: Point, second: Point?): Boolean {
+ if (second == null || first.isRunDisabled || !target.walkingQueue.isRunningBoth) {
+ return false
+ }
+ return target !is Player || target.settings.runEnergy >= 1.0
+ }
+}
diff --git a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt
index 074a656e9..ab8889466 100644
--- a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt
+++ b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt
@@ -3,7 +3,6 @@ package core.game.node.entity.combat
import content.global.ame.RandomEventNPC
import content.global.handlers.item.equipment.special.SalamanderSwingHandler
import core.game.container.impl.EquipmentContainer
-import core.game.interaction.MovementPulse
import core.game.node.Node
import core.game.node.entity.Entity
import core.game.node.entity.combat.equipment.WeaponInterface
@@ -17,7 +16,6 @@ import core.game.world.GameWorld
import core.game.world.update.flag.context.Animation
import core.tools.RandomFunction
import core.api.*
-import core.game.interaction.DestinationFlag
import core.game.system.timer.impl.*
/**
@@ -93,11 +91,6 @@ class CombatPulse(
*/
private var combatTimeOut = 0
- /**
- * The movement handling pulse.
- */
- private val movement: MovementPulse
-
/**
* The last attack sent.
*/
@@ -120,9 +113,15 @@ class CombatPulse(
return true
}
if (!interactable()) {
- return if (entity.walkingQueue.isMoving) {
+ return if (entity.walkingQueue.isMoving || entity.walkingQueue.hasPath()) {
false
- } else combatTimeOut++ > entity.properties.combatTimeOut
+ } else {
+ val timedOut = combatTimeOut++ > entity.properties.combatTimeOut
+ if (timedOut && entity is Player && !CombatMovementPlanner.hasMovementStepThisTick(victim!!)) {
+ CombatMovementIntents.stopUnreachableCombat(entity)
+ }
+ timedOut
+ }
}
combatTimeOut = 0
entity.face(victim)
@@ -195,29 +194,44 @@ class CombatPulse(
* @return `True` if so.
*/
private fun interactable(): Boolean {
- if (victim == null) {
- return false
- }
- if (entity is NPC && victim is Player && entity.isHidden(victim as Player?)) {
+ val attacker = entity ?: return false
+ val target = victim ?: return false
+ if (attacker is NPC && target is Player && attacker.isHidden(target)) {
stop()
return false
}
- if (victim is NPC && entity is Player && (victim as NPC).isHidden(entity as Player?)) {
+ if (target is NPC && attacker is Player && target.isHidden(attacker)) {
stop()
return false
}
- if (entity is NPC && !entity.asNpc().canStartCombat(victim)) {
+ if (attacker is NPC && !attacker.asNpc().canStartCombat(target)) {
stop()
return false
}
+ if (CombatMovementPlanner.exceedsCombatChaseDistance(attacker, target)) {
+ if (attacker is Player && !CombatMovementPlanner.hasMovementStepThisTick(target)) {
+ CombatMovementIntents.stopUnreachableCombat(attacker)
+ } else {
+ stop()
+ }
+ return false
+ }
+ if (style == CombatStyle.MELEE) {
+ CombatMovementIntents.trackActiveMelee(attacker, target)
+ }
val type = canInteract()
if (type == InteractionType.STILL_INTERACT) {
+ if (style == CombatStyle.MELEE && !attacker.locks.isMovementLocked &&
+ CombatMovementIntents.shouldMaintainMeleePressure(attacker, target)
+ ) {
+ CombatMovementIntents.request(attacker, target)
+ }
return true
}
- if (entity == null || victim == null || entity.locks.isMovementLocked) {
+ if (attacker.locks.isMovementLocked) {
return false
}
- movement.updatePath()
+ CombatMovementIntents.request(attacker, target)
return type == InteractionType.MOVE_INTERACT
}
@@ -300,8 +314,10 @@ class CombatPulse(
victim.scripts.removeWeakScripts()
}
- if (!isAttacking) {
+ if (!isAttacking)
entity.pulseManager.run(this)
+ if (style == CombatStyle.MELEE) {
+ CombatMovementIntents.trackActiveMelee(entity, victim)
}
}
@@ -311,8 +327,6 @@ class CombatPulse(
*/
fun setVictim(victim: Node?) {
super.addNodeCheck(1, victim)
- movement.setLast(null)
- movement.setDestination(victim)
this.victim = victim as Entity?
combatTimeOut = 0
}
@@ -362,6 +376,7 @@ class CombatPulse(
override fun stop() {
super.stop()
+ CombatMovementIntents.untrack(entity)
entity!!.setAttribute("combat-stop", GameWorld.ticks)
if (victim != null) {
lastVictim = victim
@@ -472,11 +487,4 @@ class CombatPulse(
}
}
- init {
- movement = object : MovementPulse(entity, null) {
- override fun pulse(): Boolean {
- return false
- }
- }
- }
}
diff --git a/Server/src/main/core/game/node/entity/combat/CombatReach.kt b/Server/src/main/core/game/node/entity/combat/CombatReach.kt
new file mode 100644
index 000000000..73e7a62d8
--- /dev/null
+++ b/Server/src/main/core/game/node/entity/combat/CombatReach.kt
@@ -0,0 +1,217 @@
+package core.game.node.entity.combat
+
+import core.game.container.impl.EquipmentContainer
+import core.game.node.entity.Entity
+import core.game.node.entity.npc.NPC
+import core.game.node.entity.player.Player
+import core.game.world.map.Direction
+import core.game.world.map.Location
+import core.game.world.map.RegionManager.getClippingFlag
+import core.game.world.map.path.Pathfinder
+import core.game.world.map.path.Pathfinder.*
+import core.game.world.map.path.RsmodPathfinder
+
+/** Shared combat reach calculations. */
+object CombatReach {
+ private const val BORK_LEGION_ID = 7135
+
+ @JvmStatic
+ fun isUsingHalberd(entity: Entity): Boolean {
+ if (entity is Player) {
+ val weapon = entity.equipment[EquipmentContainer.SLOT_WEAPON]
+ if (weapon != null) {
+ return weapon.id in 3190..3204 || weapon.id == 6599
+ }
+ } else if (entity is NPC) {
+ return entity.id == 8612
+ }
+ return false
+ }
+
+ @JvmStatic
+ fun meleeDistance(entity: Entity): Int {
+ return if (hasExtendedMeleeReach(entity)) 2 else 1
+ }
+
+ @JvmStatic
+ fun hasExtendedMeleeReach(entity: Entity): Boolean {
+ return isUsingHalberd(entity) || entity is NPC && entity.id == BORK_LEGION_ID
+ }
+
+ @JvmStatic
+ fun canMelee(entity: Entity, victim: Entity?, distance: Int): Boolean {
+ val e = entity.location
+ if (victim == null) {
+ return false
+ }
+ if (entity.id == BORK_LEGION_ID && entity.location.withinDistance(victim.location, 2)) {
+ return true
+ }
+ if (occupiedAreasOverlap(entity, victim)) {
+ return false
+ }
+ val x = victim.location.x
+ val y = victim.location.y
+ val size = entity.size()
+ if (distance == 1) {
+ val victimSize = victim.size()
+ fun adjacent(ex: Int, ey: Int): Boolean =
+ Pathfinder.isStandingIn(ex, ey, 1, 1, x, y, victimSize, victimSize)
+ for (i in 0 until size) {
+ if (adjacent(e.x - 1, e.y + i)) return true
+ if (adjacent(e.x + size, e.y + i)) return true
+ if (adjacent(e.x + i, e.y - 1)) return true
+ if (adjacent(e.x + i, e.y + size)) return true
+ }
+ return victim.getSwingHandler(false).type == CombatStyle.MELEE &&
+ e.withinDistance(
+ victim.location,
+ 1,
+ ) &&
+ victim.properties.combatPulse.getVictim() === entity &&
+ entity.index < victim.index
+ }
+ return entity.centerLocation.withinDistance(
+ victim.centerLocation,
+ distance + (size shr 1) + (victim.size() shr 1),
+ )
+ }
+
+ @JvmStatic
+ fun hasMeleeReach(
+ attackerLocation: Location,
+ attackerSize: Int,
+ targetLocation: Location,
+ targetSize: Int,
+ ): Boolean {
+ return RsmodPathfinder.canReach(
+ attackerLocation.x,
+ attackerLocation.y,
+ attackerSize,
+ targetLocation.x,
+ targetLocation.y,
+ targetSize,
+ targetSize,
+ 0,
+ -1,
+ 0,
+ attackerLocation.z,
+ null,
+ )
+ }
+
+ private fun occupiedAreasOverlap(first: Entity, second: Entity): Boolean {
+ return Pathfinder.isStandingIn(
+ first.location.x,
+ first.location.y,
+ first.size(),
+ first.size(),
+ second.location.x,
+ second.location.y,
+ second.size(),
+ second.size(),
+ )
+ }
+
+ @JvmStatic
+ fun combatDistance(entity: Entity, victim: Entity, rawDistance: Int): Int {
+ var distance = rawDistance
+ if (entity is NPC && entity.definition.combatDistance > 0) {
+ distance = entity.definition.combatDistance
+ }
+ return (entity.size() shr 1) + (victim.size() shr 1) + distance
+ }
+
+ @JvmStatic
+ fun canReach(entity: Entity, victim: Entity, distance: Int): Boolean {
+ return victim.centerLocation.withinDistance(entity.centerLocation, distance)
+ }
+
+ @JvmStatic
+ fun canStepTowards(entity: Entity, victim: Entity): InteractionType {
+ val closestVictimTile = victim.getClosestOccupiedTile(entity.location)
+ val closestEntityTile = entity.getClosestOccupiedTile(closestVictimTile)
+ val dir =
+ closestEntityTile.deriveDirection(closestVictimTile)
+ ?: return InteractionType.STILL_INTERACT
+ var next = closestEntityTile
+
+ // A fixed-direction walk can pass beside an oblique target without converging, so
+ // limit the skipped gap to the number of steps available from the starting distance.
+ val maxSkipSteps = next.getDistance(closestVictimTile).toInt() + 1
+ for (i in 0 until maxSkipSteps) {
+ if (next.getDistance(closestVictimTile) <= 3) {
+ break
+ }
+ next = next.transform(dir)
+ }
+ if (next.getDistance(closestVictimTile) > 3) {
+ return InteractionType.STILL_INTERACT
+ }
+
+ var result = InteractionType.STILL_INTERACT
+ val maxIterations = next.getDistance(closestVictimTile).toInt()
+ for (i in 0 until maxIterations) {
+ next = next.transform(dir)
+ result = checkStepInterval(dir, next)
+ if (result == InteractionType.NO_INTERACT) {
+ break
+ }
+ }
+
+ return result
+ }
+
+ private fun checkStepInterval(dir: Direction, next: Location): InteractionType {
+ val components = next.getStepComponents(dir)
+
+ when (dir) {
+ Direction.NORTH ->
+ if (getClippingFlag(next) and PREVENT_NORTH != 0) return InteractionType.NO_INTERACT
+ Direction.EAST ->
+ if (getClippingFlag(next) and PREVENT_EAST != 0) return InteractionType.NO_INTERACT
+ Direction.SOUTH ->
+ if (getClippingFlag(next) and PREVENT_SOUTH != 0) return InteractionType.NO_INTERACT
+ Direction.WEST ->
+ if (getClippingFlag(next) and PREVENT_WEST != 0) return InteractionType.NO_INTERACT
+
+ Direction.NORTH_EAST -> {
+ if (
+ getClippingFlag(components[0]) and PREVENT_EAST != 0 ||
+ getClippingFlag(components[1]) and PREVENT_NORTH != 0 ||
+ getClippingFlag(next) and PREVENT_NORTHEAST != 0
+ )
+ return InteractionType.NO_INTERACT
+ }
+
+ Direction.NORTH_WEST -> {
+ if (
+ getClippingFlag(components[0]) and PREVENT_WEST != 0 ||
+ getClippingFlag(components[1]) and PREVENT_NORTH != 0 ||
+ getClippingFlag(next) and PREVENT_NORTHWEST != 0
+ )
+ return InteractionType.NO_INTERACT
+ }
+
+ Direction.SOUTH_EAST -> {
+ if (
+ getClippingFlag(components[0]) and PREVENT_EAST != 0 ||
+ getClippingFlag(components[1]) and PREVENT_SOUTH != 0 ||
+ getClippingFlag(next) and PREVENT_SOUTHEAST != 0
+ )
+ return InteractionType.NO_INTERACT
+ }
+
+ Direction.SOUTH_WEST -> {
+ if (
+ getClippingFlag(components[0]) and PREVENT_WEST != 0 ||
+ getClippingFlag(components[1]) and PREVENT_SOUTH != 0 ||
+ getClippingFlag(next) and PREVENT_SOUTHWEST != 0
+ )
+ return InteractionType.NO_INTERACT
+ }
+ }
+
+ return InteractionType.STILL_INTERACT
+ }
+}
diff --git a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt
index d4324b233..013f3ad09 100644
--- a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt
+++ b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt
@@ -1,51 +1,47 @@
package core.game.node.entity.combat
+import content.global.skill.summoning.familiar.Familiar
+import core.api.log
+import core.api.playGlobalAudio
import core.game.component.Component
import core.game.container.impl.EquipmentContainer
import core.game.node.Node
import core.game.node.entity.Entity
-import core.game.node.entity.combat.equipment.*
+import core.game.node.entity.combat.equipment.ArmourSet
+import core.game.node.entity.combat.equipment.DegradableEquipment
+import core.game.node.entity.combat.equipment.WeaponInterface
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.node.entity.player.link.audio.Audio
import core.game.node.entity.player.link.prayer.PrayerType
import core.game.node.entity.skill.Skills
-import content.global.skill.summoning.familiar.Familiar
-import core.api.log
-import core.api.playGlobalAudio
-import core.game.world.map.Direction
-import core.game.world.map.Location
-import core.game.world.map.RegionManager
-import core.game.world.map.RegionManager.getClippingFlag
-import core.game.world.map.path.Pathfinder
-import core.game.world.map.path.Pathfinder.*
-import core.game.world.update.flag.context.Animation
-import core.tools.RandomFunction
import core.game.system.config.ItemConfigParser
+import core.game.world.map.path.RsmodPathfinder
+import core.game.world.update.flag.context.Animation
import core.tools.Log
+import core.tools.RandomFunction
import org.rs09.consts.Sounds
-import java.util.*
-import kotlin.math.floor
/**
* Handles a combat swing.
+ *
* @author Emperor
* @author Ceikry - Kotlin refactoring, general cleanup
* @author Player Name - converted `flags` to ArrayList
*/
abstract class CombatSwingHandler(var type: CombatStyle?) {
var flags: ArrayList = ArrayList(SwingHandlerFlag.values().size)
+
constructor(type: CombatStyle?, vararg flags: SwingHandlerFlag) : this(type) {
this.flags = arrayListOf(*flags)
}
- /**
- * The mapping of the special attack handlers.
- */
+ /** The mapping of the special attack handlers. */
private var specialHandlers: MutableMap? = null
/**
* Starts the combat swing.
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @param state The battle state instance.
@@ -55,6 +51,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Handles the impact of the combat swing (victim getting hit).
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @param state The battle state instance.
@@ -63,6 +60,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Visualizes the impact itself (end animation, end GFX, ...)
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @param state The battle state instance.
@@ -71,6 +69,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Calculates the maximum accuracy of the entity.
+ *
* @param entity The entity.
* @return The maximum accuracy value.
*/
@@ -78,6 +77,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Calculates the maximum strength of the entity.
+ *
* @param entity The entity.
* @param victim The victim.
* @param modifier The modifier.
@@ -87,6 +87,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Calculates the maximum defence of the entity.
+ *
* @param victim The entity.
* @param attacker The entity to defend against.
* @return The maximum defence value.
@@ -95,6 +96,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Gets the void set multiplier.
+ *
* @param e The entity.
* @param skillId The skill id.
* @return The multiplier.
@@ -103,6 +105,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Visualizes the combat swing (start animation, GFX, projectile, ...)
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @param state The battle state instance.
@@ -113,6 +116,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Method called when the impact method got called.
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @param state The battle state.
@@ -139,6 +143,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Gets the currently worn armour set, if any.
+ *
* @param e The entity.
* @return The armour set worn.
*/
@@ -148,6 +153,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Checks if the hit will be accurate.
+ *
* @param entity The entity.
* @param victim The victim.
* @return `True` if the hit is accurate.
@@ -158,6 +164,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Checks if the hit will be accurate.
+ *
* @param entity The entity.
* @param victim The victim.
* @param style The combat style used.
@@ -169,6 +176,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Checks if the hit will be accurate.
+ *
* @param entity The entity.
* @param victim The victim.
* @param style The combat style (null to ignore prayers).
@@ -176,26 +184,38 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
* @param defenceMod The defence modifier.
* @return `True` if the hit is accurate.
*/
- fun isAccurateImpact(entity: Entity?, victim: Entity?, style: CombatStyle?, accuracyMod: Double, defenceMod: Double): Boolean {
+ fun isAccurateImpact(
+ entity: Entity?,
+ victim: Entity?,
+ style: CombatStyle?,
+ accuracyMod: Double,
+ defenceMod: Double,
+ ): Boolean {
var mod = 1.0
if (victim == null || style == null) {
return false
}
- if (victim is Player && entity is Familiar && victim.prayer[PrayerType.PROTECT_FROM_SUMMONING]) {
+ if (
+ victim is Player &&
+ entity is Familiar &&
+ victim.prayer[PrayerType.PROTECT_FROM_SUMMONING]
+ ) {
mod = 0.0
}
val attack = calculateAccuracy(entity) * accuracyMod * mod
val defence = calculateDefence(victim, entity) * defenceMod
- val chance: Double = if (attack > defence) {
- 1 - ((defence + 2) / (2 * (attack + 1)))
- } else {
- attack / (2 * (defence + 1))
- }
+ val chance: Double =
+ if (attack > defence) {
+ 1 - ((defence + 2) / (2 * (attack + 1)))
+ } else {
+ attack / (2 * (defence + 1))
+ }
return Math.random() < chance
}
/**
* Checks if the entity can execute a combat swing at the victim.
+ *
* @param entity The entity.
* @param victim The victim.
* @return `True` if so.
@@ -206,6 +226,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Checks if the victim can be attacked by the entity.
+ *
* @param entity The attacking entity.
* @param victim The entity being attacked.
* @return `True` if so.
@@ -215,15 +236,20 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
return InteractionType.NO_INTERACT
}
- if (type == CombatStyle.MELEE) {
+ if (type == CombatStyle.MELEE && !CombatReach.hasExtendedMeleeReach(entity)) {
val stepType = canStepTowards(entity, victim)
if (stepType != InteractionType.STILL_INTERACT) return stepType
}
- val comp = entity.getAttribute("autocast_component",null) as Component?
- if((comp != null || type == CombatStyle.MAGIC) && (entity.properties.autocastSpell == null || entity.properties.autocastSpell.spellId == 0) && entity is Player){
+ val comp = entity.getAttribute("autocast_component", null) as Component?
+ if (
+ (comp != null || type == CombatStyle.MAGIC) &&
+ (entity.properties.autocastSpell == null ||
+ entity.properties.autocastSpell.spellId == 0) &&
+ entity is Player
+ ) {
val weapEx = entity.getExtension(WeaponInterface::class.java) as WeaponInterface?
- if(comp != null){
+ if (comp != null) {
entity.interfaceManager.close(comp)
entity.interfaceManager.openTab(weapEx)
entity.properties.combatPulse.stop()
@@ -234,7 +260,12 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
entity.debug("Adjusting attack style")
}
if (entity.location == victim.location) {
- return if (entity is Player && victim is Player && entity.clientIndex < victim.clientIndex && victim.properties.combatPulse.getVictim() === entity) {
+ return if (
+ entity is Player &&
+ victim is Player &&
+ entity.clientIndex < victim.clientIndex &&
+ victim.properties.combatPulse.getVictim() === entity
+ ) {
InteractionType.STILL_INTERACT
} else InteractionType.NO_INTERACT
}
@@ -248,81 +279,12 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
}
protected fun canStepTowards(entity: Entity, victim: Entity): InteractionType {
- val closestVictimTile = victim.getClosestOccupiedTile(entity.location)
- val closestEntityTile = entity.getClosestOccupiedTile(closestVictimTile)
- val dir = closestEntityTile.deriveDirection(closestVictimTile)
- ?: return InteractionType.STILL_INTERACT //if we cannot derive a direction, it's because both tiles are the same, so hand off control to the main logic which already handles this case
- var next = closestEntityTile
-
- //Skip the initial gap in distance if it exists, because standard pathfinding would already stop us before this point if something was between us and the NPC or vice versa.
- //A fixed-direction walk can only arrive within its starting distance in steps; on oblique approaches it
- //passes beside the target and never converges, so the walk is hard-capped at that many steps.
- val maxSkipSteps = next.getDistance(closestVictimTile).toInt() + 1
- for (i in 0 until maxSkipSteps) {
- if (next.getDistance(closestVictimTile) <= 3) break
- next = next.transform(dir)
- }
- if (next.getDistance(closestVictimTile) > 3) return InteractionType.STILL_INTERACT //never converged (oblique approach), so defer to the range checks instead
-
- var result: InteractionType = InteractionType.STILL_INTERACT
- val maxIterations = next.getDistance(closestVictimTile).toInt()
- for (i in 0 until maxIterations) { //step towards the target tile, checking if anything would obstruct us on the way, and immediately breaking + returning if it does.
- next = next.transform(dir)
- result = checkStepInterval(dir, next)
- if (result == InteractionType.NO_INTERACT) break
- }
-
-
- return result
- }
-
- private fun checkStepInterval(
- dir: Direction,
- next: Location
- ): InteractionType {
- val components = next.getStepComponents(dir)
-
- when (dir) {
- Direction.NORTH -> if (getClippingFlag(next) and PREVENT_NORTH != 0) return InteractionType.NO_INTERACT
- Direction.EAST -> if (getClippingFlag(next) and PREVENT_EAST != 0) return InteractionType.NO_INTERACT
- Direction.SOUTH -> if (getClippingFlag(next) and PREVENT_SOUTH != 0) return InteractionType.NO_INTERACT
- Direction.WEST -> if (getClippingFlag(next) and PREVENT_WEST != 0) return InteractionType.NO_INTERACT
-
- Direction.NORTH_EAST -> {
- if (getClippingFlag(components[0]) and PREVENT_EAST != 0
- || getClippingFlag(components[1]) and PREVENT_NORTH != 0
- || getClippingFlag(next) and PREVENT_NORTHEAST != 0
- ) return InteractionType.NO_INTERACT
- }
-
- Direction.NORTH_WEST -> {
- if (getClippingFlag(components[0]) and PREVENT_WEST != 0
- || getClippingFlag(components[1]) and PREVENT_NORTH != 0
- || getClippingFlag(next) and PREVENT_NORTHWEST != 0
- ) return InteractionType.NO_INTERACT
- }
-
- Direction.SOUTH_EAST -> {
- if (getClippingFlag(components[0]) and PREVENT_EAST != 0
- || getClippingFlag(components[1]) and PREVENT_SOUTH != 0
- || getClippingFlag(next) and PREVENT_SOUTHEAST != 0
- ) return InteractionType.NO_INTERACT
- }
-
- Direction.SOUTH_WEST -> {
- if (getClippingFlag(components[0]) and PREVENT_WEST != 0
- || getClippingFlag(components[1]) and PREVENT_SOUTH != 0
- || getClippingFlag(next) and PREVENT_SOUTHWEST != 0
- ) return InteractionType.NO_INTERACT
- }
-
- }
-
- return InteractionType.STILL_INTERACT
+ return CombatReach.canStepTowards(entity, victim)
}
/**
* Gets the dragonfire message.
+ *
* @param protection The protection value.
* @param fireName The fire breath name.
* @return The message to send.
@@ -345,6 +307,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Visualizes the audio.
+ *
* @param entity the entity.
* @param victim the victim.
* @param state the state.
@@ -354,7 +317,11 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
val styleIndex = entity.settings.attackStyleIndex
if (state.weapon != null && state.weapon.item != null) {
val weapon = state.weapon.item
- val audios = weapon.definition.getConfiguration>(ItemConfigParser.ATTACK_AUDIO, null)
+ val audios =
+ weapon.definition.getConfiguration>(
+ ItemConfigParser.ATTACK_AUDIO,
+ null,
+ )
if (audios != null) {
var audio: Audio? = null
if (styleIndex < audios.size) {
@@ -366,7 +333,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
playGlobalAudio(entity.location, audio.id)
}
} else if (type == CombatStyle.MELEE) {
- //plays a punching sound when no weapon is equipped
+ // plays a punching sound when no weapon is equipped
playGlobalAudio(entity.location, Sounds.HUMAN_ATTACK_2564)
}
} else if (entity is NPC) {
@@ -378,24 +345,20 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Gets the combat distance.
+ *
* @param e The entity.
* @param v The victim.
* @param rawDistance The distance.
* @return The actual distance used for combat.
*/
open fun getCombatDistance(e: Entity, v: Entity, rawDistance: Int): Int {
- var distance = rawDistance
- if (e is NPC) {
- if (e.definition.combatDistance > 0) {
- distance = e.definition.combatDistance
- }
- }
- return (e.size() shr 1) + (v.size() shr 1) + distance
+ return CombatReach.combatDistance(e, v, rawDistance)
}
/**
- * Formats the hit for the victim. (called as
- * victim.getSwingHandler(false).formatHit(victim, hit))
+ * Formats the hit for the victim. (called as victim.getSwingHandler(false).formatHit(victim,
+ * hit))
+ *
* @param victim The entity receiving the hit.
* @param rawHit The hit to format.
* @return The formatted hit.
@@ -413,6 +376,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Adjusts the battle state object for this combat swing.
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @param state The battle state.
@@ -425,8 +389,11 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
}
entity.sendImpact(state)
victim.checkImpact(state)
- //Prevents lumbridge dummies from dying (true to how rs3 / 2009scape in 2009 does it)
- if (victim.id == 4474 && type == CombatStyle.MAGIC || victim.id == 7891 && type == CombatStyle.MELEE) {
+ // Prevents lumbridge dummies from dying (true to how rs3 / 2009scape in 2009 does it)
+ if (
+ victim.id == 4474 && type == CombatStyle.MAGIC ||
+ victim.id == 7891 && type == CombatStyle.MELEE
+ ) {
EXPERIENCE_MOD = 0.1
victim.fullRestore()
if (state.estimatedHit >= 15) {
@@ -439,7 +406,8 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
if (victim.id == 757) {
EXPERIENCE_MOD = 0.01
}
- // Recursively adjustBattleState targets so that multi-target attacks have protection prayers applied.
+ // Recursively adjustBattleState targets so that multi-target attacks have protection
+ // prayers applied.
if (state.targets != null && state.targets.isNotEmpty()) {
if (!(state.targets.size == 1 && state.targets[0] == state)) {
for (s in state.targets) {
@@ -467,7 +435,10 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
}
}
if (victim is NPC) {
- if (victim.properties.protectStyle != null && state.style == victim.properties.protectStyle) {
+ if (
+ victim.properties.protectStyle != null &&
+ state.style == victim.properties.protectStyle
+ ) {
state.neutralizeHits()
}
}
@@ -475,21 +446,31 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Adds the experience for the current combat swing.
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @param state The battle state.
*/
open fun addExperience(entity: Entity?, victim: Entity?, state: BattleState?) {
- if (entity == null || (victim is Player && entity is Player && entity.asPlayer().ironmanManager.isIronman)) {
+ if (
+ entity == null ||
+ (victim is Player && entity is Player && entity.asPlayer().ironmanManager.isIronman)
+ ) {
return
}
var player: Player
var attStyle: Int
- when(entity)
- {
- is Familiar -> {player = entity.owner; attStyle = entity.attackStyle}
- is Player -> {player = entity; attStyle = entity.properties.attackStyle.style}
+ when (entity) {
+ is Familiar -> {
+ player = entity.owner
+ attStyle = entity.attackStyle
+ }
+
+ is Player -> {
+ player = entity
+ attStyle = entity.properties.attackStyle.style
+ }
else -> return
}
if (victim is NPC) EXPERIENCE_MOD *= victim.behavior.getXpMultiplier(victim, player)
@@ -538,20 +519,26 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
}
/**
- * Hook for operations that conceptually happen during swing but could mess with experience granting logic if they
- * happened earlier.
+ * Hook for operations that conceptually happen during swing but could mess with experience
+ * granting logic if they happened earlier.
*/
open fun postSwing(entity: Entity?, victim: Entity?, state: BattleState?) {}
/**
* Gets the formated hit.
+ *
* @param attacker The attacking entity.
* @param victim The victim.
* @param state The battle state.
* @param rawHit The hit to format.
* @return The formated hit.
*/
- protected open fun getFormattedHit(attacker: Entity, victim: Entity, state: BattleState, rawHit: Int): Int {
+ protected open fun getFormattedHit(
+ attacker: Entity,
+ victim: Entity,
+ state: BattleState,
+ rawHit: Int,
+ ): Int {
var hit = rawHit
hit = attacker.getFormattedHit(state, hit).toInt()
if (victim is Player) {
@@ -582,7 +569,11 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
}
if (attacker is Player) {
val player = attacker.asPlayer()
- if (player.equipment[3] != null && player.equipment[3].id == 14726 && state.style == CombatStyle.MAGIC) {
+ if (
+ player.equipment[3] != null &&
+ player.equipment[3].id == 14726 &&
+ state.style == CombatStyle.MAGIC
+ ) {
hit += (hit.toDouble() * 0.15).toInt()
}
}
@@ -596,6 +587,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Gets the default animation of the entity.
+ *
* @param e The entity.
* @param style The combat style.
* @return The attack animation.
@@ -610,6 +602,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Registers a special attack handler.
+ *
* @param itemId The item id.
* @param handler The combat swing handler.
* @return `True` if succesful.
@@ -619,7 +612,17 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
specialHandlers = HashMap()
}
if (specialHandlers!!.containsKey(itemId)) {
- log(this::class.java, Log.ERR, "Already contained special attack handler for item " + itemId + " - [old=" + specialHandlers!![itemId]!!::class.java.simpleName + ", new=" + handler.javaClass.simpleName + "].")
+ log(
+ this::class.java,
+ Log.ERR,
+ "Already contained special attack handler for item " +
+ itemId +
+ " - [old=" +
+ specialHandlers!![itemId]!!::class.java.simpleName +
+ ", new=" +
+ handler.javaClass.simpleName +
+ "].",
+ )
return false
}
return specialHandlers!!.put(itemId, handler) == null
@@ -627,9 +630,9 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
/**
* Gets the special attack handler for the given item id.
+ *
* @param itemId The item id.
- * @return The special attack handler, or `null` if this item has no
- * special attack handler.
+ * @return The special attack handler, or `null` if this item has no special attack handler.
*/
fun getSpecial(itemId: Int): CombatSwingHandler? {
if (specialHandlers == null) {
@@ -639,41 +642,30 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
}
companion object {
- /**
- * The amount of experience to get per hit.
- */
- @JvmField
- var EXPERIENCE_MOD = 4.0
+ /** The amount of experience to get per hit. */
+ @JvmField
+ var EXPERIENCE_MOD = 4.0
/**
- * Checks if a projectile can be fired from the node location to the victim
- * location.
+ * Checks if a projectile can be fired from the node location to the victim location.
+ *
* @param entity The node.
* @param victim The victim.
- * @param checkClose If we are checking for a melee attack rather than a
- * projectile.
+ * @param checkClose If we are checking for a melee attack rather than a projectile.
* @return `True` if so.
*/
- @JvmStatic
- fun isProjectileClipped(entity: Node, victim: Node?, checkClose: Boolean): Boolean {
- for(x1 in 0 until entity.size()) {
- for(y1 in 0 until entity.size()) {
- val src = entity.location.transform(x1, y1, 0)
- for(x2 in 0 until victim!!.size()) {
- for(y2 in 0 until victim!!.size()) {
- val dst = victim!!.location.transform(x2, y2, 0)
- val path = PROJECTILE.find(src, 1, dst, 1, 1, 0, 0, 0, false, RegionManager::getClippingFlag)
- if(path.isSuccessful && (!checkClose || path.points.size <= 1)) {
- return true
- }
- }
- }
- }
- }
- return false
+ @JvmStatic
+ fun isProjectileClipped(entity: Node, victim: Node?, checkClose: Boolean): Boolean {
+ val target = requireNotNull(victim)
+ return RsmodPathfinder.hasLineOfSightBetween(
+ entity.location,
+ entity.size(),
+ target.location,
+ target.size(),
+ maxRaySteps = if (checkClose) 1 else Int.MAX_VALUE,
+ )
}
}
-
}
enum class SwingHandlerFlag {
@@ -681,5 +673,5 @@ enum class SwingHandlerFlag {
IGNORE_STAT_BOOSTS_ACCURACY,
IGNORE_PRAYER_BOOSTS_DAMAGE,
IGNORE_PRAYER_BOOSTS_ACCURACY,
- IGNORE_STAT_REDUCTION
+ IGNORE_STAT_REDUCTION,
}
diff --git a/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt
index 0062c20d9..8a366ed89 100644
--- a/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt
+++ b/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt
@@ -26,9 +26,9 @@ open class MagicSwingHandler (vararg flags: SwingHandlerFlag)
}
var distance = 10
var type = InteractionType.STILL_INTERACT
- var goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, distance))
+ var goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, distance))
if (victim.walkingQueue.isMoving && !goodRange) {
- goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, ++distance))
+ goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, ++distance))
type = InteractionType.MOVE_INTERACT
}
if (goodRange && isAttackable(entity, victim) != InteractionType.NO_INTERACT) {
diff --git a/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt
index 7f9b668d8..fa4c7daba 100644
--- a/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt
+++ b/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt
@@ -2,12 +2,9 @@ package core.game.node.entity.combat
import content.global.skill.skillcapeperks.SkillcapePerks
import content.global.skill.slayer.SlayerEquipmentFlags
-import content.global.skill.slayer.SlayerManager
import content.global.skill.slayer.Tasks
import content.global.skill.summoning.SummoningPouch
import core.api.*
-import core.api.EquipmentSlot
-import core.game.container.impl.EquipmentContainer
import core.game.node.entity.Entity
import core.game.node.entity.combat.equipment.ArmourSet
import core.game.node.entity.combat.equipment.Weapon
@@ -15,50 +12,165 @@ import core.game.node.entity.combat.equipment.WeaponInterface
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.node.entity.skill.Skills
-import core.game.world.map.path.Pathfinder
+import core.game.world.map.Direction
+import core.game.world.map.Location
+import core.game.world.map.RegionManager
import core.tools.RandomFunction
import org.rs09.consts.Items
-import kotlin.math.ceil
import kotlin.math.floor
/**
* Handles a melee combat swing.
+ *
* @author Emperor
* @author Ceikry, Kotlin conversion + cleanup
*/
-open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
-/**
- * Constructs a new `MeleeSwingHandler` {@Code Object}.
- */
+open class MeleeSwingHandler(vararg flags: SwingHandlerFlag)
+/** Constructs a new `MeleeSwingHandler` {@Code Object}. */
: CombatSwingHandler(CombatStyle.MELEE, *flags) {
- override fun canSwing(entity : Entity, victim : Entity) : InteractionType? {
- //Credits wolfenzi, https://www.rune-server.ee/2009scape-development/rs2-server/snippets/608720-arios-hybridding-improve.html
- var distance = if (usingHalberd(entity)) 2 else 1
- var type = InteractionType.STILL_INTERACT
- var goodRange = canMelee(entity, victim, distance)
- if (!goodRange && victim.properties.combatPulse.getVictim() !== entity && victim.walkingQueue.isMoving && entity.size() == 1) {
- type = InteractionType.MOVE_INTERACT
- distance += if (entity.walkingQueue.isRunningBoth) 2 else 1
- goodRange = canMelee(entity, victim, distance)
- }
- if (!isProjectileClipped(entity, victim, !usingHalberd(entity))) {
- return InteractionType.NO_INTERACT
- }
- val isRunning = entity.walkingQueue.runDir != -1
- val enemyRunning = victim.walkingQueue.runDir != -1
- // THX 4 fix tom <333.
- if (super.canSwing(entity, victim) != InteractionType.NO_INTERACT) {
- val maxDistance = if (isRunning) if (enemyRunning) 3 else 4 else 2
- if (entity.walkingQueue.isMoving && entity.location.getDistance(victim.location) <= maxDistance && goodRange) {
- return type
- } else if (goodRange) {
- if (canStepTowards(entity, victim) == InteractionType.NO_INTERACT) return InteractionType.NO_INTERACT
- if (type == InteractionType.STILL_INTERACT) entity.walkingQueue.reset()
- return type
- }
- }
- return InteractionType.NO_INTERACT
- }
+ override fun canSwing(entity: Entity, victim: Entity): InteractionType? {
+ // Credits wolfenzi,
+ // https://www.rune-server.ee/2009scape-development/rs2-server/snippets/608720-arios-hybridding-improve.html
+ var distance = CombatReach.meleeDistance(entity)
+ var type = InteractionType.STILL_INTERACT
+ var goodRange = CombatReach.canMelee(entity, victim, distance)
+ if (
+ !goodRange &&
+ victim.properties.combatPulse.getVictim() !== entity &&
+ victim.walkingQueue.isMoving &&
+ entity.size() == 1
+ ) {
+ type = InteractionType.MOVE_INTERACT
+ distance += if (entity.walkingQueue.isRunningBoth) 2 else 1
+ goodRange = CombatReach.canMelee(entity, victim, distance)
+ }
+ if (!hasMeleeLineOfSight(entity, victim, type)) {
+ return InteractionType.NO_INTERACT
+ }
+ val isRunning = entity.walkingQueue.runDir != -1
+ val enemyRunning = victim.walkingQueue.runDir != -1
+ // THX 4 fix tom <333.
+ if (super.canSwing(entity, victim) != InteractionType.NO_INTERACT) {
+ val maxDistance = if (isRunning) if (enemyRunning) 3 else 4 else 2
+ if (
+ entity.walkingQueue.isMoving &&
+ entity.location.getDistance(victim.location) <= maxDistance &&
+ goodRange
+ ) {
+ return type
+ } else if (goodRange) {
+ if (
+ !CombatReach.hasExtendedMeleeReach(entity) &&
+ canStepTowards(entity, victim) == InteractionType.NO_INTERACT
+ )
+ return InteractionType.NO_INTERACT
+ if (type == InteractionType.STILL_INTERACT) entity.walkingQueue.reset()
+ return type
+ }
+ }
+ return InteractionType.NO_INTERACT
+ }
+
+ private fun hasMeleeLineOfSight(
+ entity: Entity,
+ victim: Entity,
+ type: InteractionType,
+ ): Boolean {
+ if (CombatReach.hasExtendedMeleeReach(entity)) {
+ return isProjectileClipped(entity, victim, false)
+ }
+ if (
+ CombatReach.hasMeleeReach(
+ entity.location,
+ entity.size(),
+ victim.location,
+ victim.size(),
+ )
+ ) {
+ return true
+ }
+ if (type != InteractionType.MOVE_INTERACT) {
+ return false
+ }
+ val predictedVictimLocation =
+ CombatMovementPlanner.predictedMovementLocation(victim) ?: return false
+ val projectedEntityLocation =
+ projectedMeleeChaseLocation(entity, victim, predictedVictimLocation) ?: return false
+ if (!isAdjacentTo(entity, projectedEntityLocation, victim, predictedVictimLocation)) {
+ return false
+ }
+ return CombatReach.hasMeleeReach(
+ projectedEntityLocation,
+ entity.size(),
+ predictedVictimLocation,
+ victim.size(),
+ )
+ }
+
+ private fun projectedMeleeChaseLocation(
+ entity: Entity,
+ victim: Entity,
+ victimLocation: Location,
+ ): Location? {
+ if (entity.size() != 1 || entity.location.z != victimLocation.z) {
+ return null
+ }
+ val maxSteps =
+ if (
+ entity is Player &&
+ entity.walkingQueue.isRunningBoth &&
+ entity.settings.runEnergy >= 1.0
+ )
+ 2
+ else 1
+ var current = entity.location
+ var steps = 0
+ while (steps < maxSteps && !isAdjacentTo(entity, current, victim, victimLocation)) {
+ val direction = Direction.getDirection(current, victimLocation) ?: return null
+ if (
+ !direction.canMoveFrom(
+ current.z,
+ current.x,
+ current.y,
+ RegionManager::getClippingFlag,
+ )
+ ) {
+ return null
+ }
+ val next = current.transform(direction)
+ if (!RegionManager.isTeleportPermitted(next)) {
+ return null
+ }
+ current = next
+ steps++
+ }
+ return current
+ }
+
+ private fun isAdjacentTo(
+ entity: Entity,
+ entityLocation: Location,
+ victim: Entity,
+ victimLocation: Location,
+ ): Boolean {
+ val entityMinX = entityLocation.x
+ val entityMaxX = entityLocation.x + entity.size()
+ val entityMinY = entityLocation.y
+ val entityMaxY = entityLocation.y + entity.size()
+ val victimMinX = victimLocation.x
+ val victimMaxX = victimLocation.x + victim.size()
+ val victimMinY = victimLocation.y
+ val victimMaxY = victimLocation.y + victim.size()
+
+ val xOverlaps = entityMinX < victimMaxX && entityMaxX > victimMinX
+ val yOverlaps = entityMinY < victimMaxY && entityMaxY > victimMinY
+ if (xOverlaps && yOverlaps) {
+ return false
+ }
+ val xTouches = entityMaxX == victimMinX || victimMaxX == entityMinX
+ val yTouches = entityMaxY == victimMinY || victimMaxY == entityMinY
+ return (xTouches && yOverlaps) || (yTouches && xOverlaps)
+ }
override fun swing(entity: Entity?, victim: Entity?, state: BattleState?): Int {
var hit = 0
@@ -69,18 +181,29 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
if (entity!!.properties.armourSet == ArmourSet.VERAC && RandomFunction.roll(4)) {
state.armourEffect = ArmourSet.VERAC
}
- if (state.armourEffect == ArmourSet.VERAC || isAccurateImpact(entity, victim, CombatStyle.MELEE)) {
+ if (
+ state.armourEffect == ArmourSet.VERAC ||
+ isAccurateImpact(entity, victim, CombatStyle.MELEE)
+ ) {
var max = calculateHit(entity, victim, 1.0)
if (victim != null) {
- if (entity is NPC && state.armourEffect == ArmourSet.VERAC && victim.hasProtectionPrayer(CombatStyle.MELEE)) max = max * 2 / 3
+ if (
+ entity is NPC &&
+ state.armourEffect == ArmourSet.VERAC &&
+ victim.hasProtectionPrayer(CombatStyle.MELEE)
+ )
+ max = max * 2 / 3
}
state.maximumHit = max
hit = RandomFunction.random(max + 1)
}
state.estimatedHit = hit
- if(victim != null) {
- if (state.estimatedHit > victim.skills.lifepoints) state.estimatedHit = victim.skills.lifepoints
- if (state.estimatedHit + state.secondaryHit > victim.skills.lifepoints) state.secondaryHit -= ((state.estimatedHit + state.secondaryHit) - victim.skills.lifepoints)
+ if (victim != null) {
+ if (state.estimatedHit > victim.skills.lifepoints)
+ state.estimatedHit = victim.skills.lifepoints
+ if (state.estimatedHit + state.secondaryHit > victim.skills.lifepoints)
+ state.secondaryHit -=
+ ((state.estimatedHit + state.secondaryHit) - victim.skills.lifepoints)
}
return 1
}
@@ -123,7 +246,7 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
damage = 20
}
if (damage > -1 && RandomFunction.random(10) < 4) {
- applyPoison (victim, entity, damage)
+ applyPoison(victim, entity, damage)
}
}
} else if (entity is NPC) {
@@ -131,26 +254,36 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
val damage = entity.poisonSeverity()
if (poisonous && damage > -1 && RandomFunction.random(10) < 4) {
- applyPoison (victim, entity, damage)
+ applyPoison(victim, entity, damage)
}
}
super.adjustBattleState(entity, victim, state)
}
override fun calculateAccuracy(entity: Entity?): Int {
- //formula taken from wiki: https://oldschool.runescape.wiki/w/Damage_per_second/Melee#Step_six:_Calculate_the_hit_chance Yes I know it's old school. It's the best resource we have for potentially authentic formulae.
+ // formula taken from wiki:
+ // https://oldschool.runescape.wiki/w/Damage_per_second/Melee#Step_six:_Calculate_the_hit_chance Yes I know it's old school. It's the best resource we have for potentially authentic formulae.
entity ?: return 0
- val styleAttackBonus = entity.properties.bonuses[entity.properties.attackStyle.bonusType] + 64
+ val styleAttackBonus =
+ entity.properties.bonuses[entity.properties.attackStyle.bonusType] + 64
when (entity) {
is Player -> {
var effectiveAttackLevel = entity.skills.getLevel(Skills.ATTACK).toDouble()
- if(!flags.contains(SwingHandlerFlag.IGNORE_PRAYER_BOOSTS_ACCURACY))
- effectiveAttackLevel = floor(effectiveAttackLevel + (entity.prayer.getSkillBonus(Skills.ATTACK) * effectiveAttackLevel))
- if(entity.properties.attackStyle.style == WeaponInterface.STYLE_ACCURATE) effectiveAttackLevel += 3
- else if(entity.properties.attackStyle.style == WeaponInterface.STYLE_CONTROLLED) effectiveAttackLevel += 1
+ if (!flags.contains(SwingHandlerFlag.IGNORE_PRAYER_BOOSTS_ACCURACY))
+ effectiveAttackLevel =
+ floor(
+ effectiveAttackLevel +
+ (entity.prayer.getSkillBonus(Skills.ATTACK) * effectiveAttackLevel)
+ )
+ if (entity.properties.attackStyle.style == WeaponInterface.STYLE_ACCURATE)
+ effectiveAttackLevel += 3
+ else if (entity.properties.attackStyle.style == WeaponInterface.STYLE_CONTROLLED)
+ effectiveAttackLevel += 1
effectiveAttackLevel += 8
- if(SkillcapePerks.isActive(SkillcapePerks.PRECISION_STRIKES, entity)){ //Attack skillcape perk
+ if (
+ SkillcapePerks.isActive(SkillcapePerks.PRECISION_STRIKES, entity)
+ ) { // Attack skillcape perk
effectiveAttackLevel += 6
}
effectiveAttackLevel *= getSetMultiplier(entity, Skills.ATTACK)
@@ -163,14 +296,27 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
// attack bonus for specialized equipments (salve amulets, slayer equips)
val amuletId = getItemFromEquipment(entity, EquipmentSlot.NECK)?.id ?: 0
- if ((amuletId == Items.SALVE_AMULET_4081 || amuletId == Items.SALVE_AMULETE_10588) && checkUndead(victimName)) {
+ if (
+ (amuletId == Items.SALVE_AMULET_4081 ||
+ amuletId == Items.SALVE_AMULETE_10588) && checkUndead(victimName)
+ ) {
effectiveAttackLevel *= if (amuletId == Items.SALVE_AMULET_4081) 1.15 else 1.2
- } else if (getSlayerTask(entity)?.let { task ->
+ } else if (
+ getSlayerTask(entity)?.let { task ->
val victimId = entity.properties.combatPulse?.getVictim()?.id ?: 0
- task.ids.contains(victimId) || (task == Tasks.KALPHITES && (victimId == 1158)) // Kalphite Queen phase 1
- } == true) {
- effectiveAttackLevel *= SlayerEquipmentFlags.getDamAccBonus(entity) //Slayer Helm/ Black Mask/ Slayer cape
- if (getSlayerTask(entity)?.dragon == true && inEquipment(entity, Items.DRAGON_SLAYER_GLOVES_12862))
+ task.ids.contains(victimId) ||
+ (task == Tasks.KALPHITES &&
+ (victimId == 1158)) // Kalphite Queen phase 1
+ } == true
+ ) {
+ effectiveAttackLevel *=
+ SlayerEquipmentFlags.getDamAccBonus(
+ entity
+ ) // Slayer Helm/ Black Mask/ Slayer cape
+ if (
+ getSlayerTask(entity)?.dragon == true &&
+ inEquipment(entity, Items.DRAGON_SLAYER_GLOVES_12862)
+ )
effectiveAttackLevel *= 1.1
}
@@ -183,8 +329,6 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
}
return 0
-
-
}
override fun calculateHit(entity: Entity?, victim: Entity?, modifier: Double): Int {
@@ -194,28 +338,43 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
when (entity) {
is Player -> {
var effectiveStrengthLevel = entity.skills.getLevel(Skills.STRENGTH).toDouble()
- if(!flags.contains(SwingHandlerFlag.IGNORE_PRAYER_BOOSTS_DAMAGE))
- effectiveStrengthLevel = floor(effectiveStrengthLevel + (entity.prayer.getSkillBonus(Skills.STRENGTH) * effectiveStrengthLevel))
- if(entity.properties.attackStyle.style == WeaponInterface.STYLE_AGGRESSIVE) effectiveStrengthLevel += 3
- else if (entity.properties.attackStyle.style == WeaponInterface.STYLE_CONTROLLED) effectiveStrengthLevel += 1
+ if (!flags.contains(SwingHandlerFlag.IGNORE_PRAYER_BOOSTS_DAMAGE))
+ effectiveStrengthLevel =
+ floor(
+ effectiveStrengthLevel +
+ (entity.prayer.getSkillBonus(Skills.STRENGTH) *
+ effectiveStrengthLevel)
+ )
+ if (entity.properties.attackStyle.style == WeaponInterface.STYLE_AGGRESSIVE)
+ effectiveStrengthLevel += 3
+ else if (entity.properties.attackStyle.style == WeaponInterface.STYLE_CONTROLLED)
+ effectiveStrengthLevel += 1
effectiveStrengthLevel += 8
effectiveStrengthLevel *= getSetMultiplier(entity, Skills.STRENGTH)
effectiveStrengthLevel = floor(effectiveStrengthLevel)
if (!flags.contains(SwingHandlerFlag.IGNORE_STAT_BOOSTS_DAMAGE))
effectiveStrengthLevel *= styleStrengthBonus
else effectiveStrengthLevel *= 64
- if (getSlayerTask(entity)?.let { task ->
- val victimId = entity.properties.combatPulse?.getVictim()?.id ?: 0
- task.ids.contains(victimId) || (task == Tasks.KALPHITES && (victimId == 1158)) // Kalphite Queen phase 1
- } == true) {
- effectiveStrengthLevel *= SlayerEquipmentFlags.getDamAccBonus(entity) //Slayer Helm/ Black Mask/ Slayer cape
+ if (
+ getSlayerTask(entity)?.let { task ->
+ val victimId = entity.properties.combatPulse?.getVictim()?.id ?: 0
+ task.ids.contains(victimId) ||
+ (task == Tasks.KALPHITES &&
+ (victimId == 1158)) // Kalphite Queen phase 1
+ } == true
+ ) {
+ effectiveStrengthLevel *=
+ SlayerEquipmentFlags.getDamAccBonus(
+ entity
+ ) // Slayer Helm/ Black Mask/ Slayer cape
}
return (floor((0.5 + (effectiveStrengthLevel / 640.0))) * modifier).toInt()
}
is NPC -> {
val strengthLevel = entity.skills.getLevel(Skills.STRENGTH) + 9
- return (floor((0.5 + (strengthLevel * styleStrengthBonus / 640.0))) * modifier).toInt()
+ return (floor((0.5 + (strengthLevel * styleStrengthBonus / 640.0))) * modifier)
+ .toInt()
}
}
@@ -223,17 +382,28 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
}
override fun calculateDefence(victim: Entity?, attacker: Entity?): Int {
- //authentic formula, taken from OSRS wiki: https://oldschool.runescape.wiki/w/Damage_per_second/Melee#Step_five:_Calculate_the_Defence_roll
+ // authentic formula, taken from OSRS wiki:
+ // https://oldschool.runescape.wiki/w/Damage_per_second/Melee#Step_five:_Calculate_the_Defence_roll
victim ?: return 0
attacker ?: return 0
- val styleDefenceBonus = victim.properties.bonuses[attacker.properties.attackStyle.bonusType + 5] + 64
+ val styleDefenceBonus =
+ victim.properties.bonuses[attacker.properties.attackStyle.bonusType + 5] + 64
when (victim) {
is Player -> {
var effectiveDefenceLevel = victim.skills.getLevel(Skills.DEFENCE).toDouble()
- effectiveDefenceLevel = floor(effectiveDefenceLevel + (victim.prayer.getSkillBonus(Skills.DEFENCE) * effectiveDefenceLevel))
- if (victim.properties.attackStyle.style == WeaponInterface.STYLE_DEFENSIVE || victim.properties.attackStyle.style == WeaponInterface.STYLE_LONG_RANGE) effectiveDefenceLevel += 3
- else if (victim.properties.attackStyle.style == WeaponInterface.STYLE_CONTROLLED) effectiveDefenceLevel += 1
+ effectiveDefenceLevel =
+ floor(
+ effectiveDefenceLevel +
+ (victim.prayer.getSkillBonus(Skills.DEFENCE) * effectiveDefenceLevel)
+ )
+ if (
+ victim.properties.attackStyle.style == WeaponInterface.STYLE_DEFENSIVE ||
+ victim.properties.attackStyle.style == WeaponInterface.STYLE_LONG_RANGE
+ )
+ effectiveDefenceLevel += 3
+ else if (victim.properties.attackStyle.style == WeaponInterface.STYLE_CONTROLLED)
+ effectiveDefenceLevel += 1
effectiveDefenceLevel += 8
effectiveDefenceLevel *= getSetMultiplier(victim, Skills.DEFENCE)
effectiveDefenceLevel *= familiarDefenceBonus(victim)
@@ -283,12 +453,23 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
/**
* Check to see whether an NPC is classified as undead.
+ *
* @param name
* @return true if so
*/
private fun checkUndead(name: String): Boolean {
- return (name == "Zombie" || name.contains("rmoured") || name == "Ankou" || name == "Crawling Hand" || name == "Banshee" || name == "Ghost" || name == "Ghast" || name == "Mummy" || name.contains("Revenant")
- || name == "Skeleton" || name == "Zogre" || name == "Spiritual Mage")
+ return (name == "Zombie" ||
+ name.contains("rmoured") ||
+ name == "Ankou" ||
+ name == "Crawling Hand" ||
+ name == "Banshee" ||
+ name == "Ghost" ||
+ name == "Ghast" ||
+ name == "Mummy" ||
+ name.contains("Revenant") ||
+ name == "Skeleton" ||
+ name == "Zogre" ||
+ name == "Spiritual Mage")
}
/**
@@ -298,11 +479,12 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
*/
private fun familiarDefenceBonus(e: Entity?): Double {
if (e !is Player) return 1.0
- val fam = try {
- e.familiarManager?.familiar
- } catch (ex: Exception) {
- null
- } ?: return 1.0
+ val fam =
+ try {
+ e.familiarManager?.familiar
+ } catch (ex: Exception) {
+ null
+ } ?: return 1.0
return when (fam.pouchId) {
SummoningPouch.IRON_TITAN_POUCH.pouchId -> 1.10
SummoningPouch.STEEL_TITAN_POUCH.pouchId -> 1.15
@@ -311,61 +493,15 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
}
companion object {
- /**
- * Checks if the entity is using a halberd.
- * @param entity The entity.
- * @return `True` if so.
- */
- private fun usingHalberd(entity: Entity): Boolean {
- if (entity is Player) {
- val weapon = entity.equipment[EquipmentContainer.SLOT_WEAPON]
- if (weapon != null) {
- return weapon.id in 3190..3204 || weapon.id == 6599
- }
- } else if (entity is NPC) {
- return entity.id == 8612
- }
- return false
- }
-
/**
* Checks if the entity can execute a melee swing from its current location.
+ *
* @param entity The attacking entity.
* @param victim The victim.
* @return `True` if so.
*/
fun canMelee(entity: Entity, victim: Entity?, distance: Int): Boolean {
- val e = entity.location
- if (victim == null) {
- return false
- }
- if (entity.id == 7135 && entity.location.withinDistance(victim.location, 2)) {
- return true
- }
- val x = victim.location.x
- val y = victim.location.y
- val size = entity.size()
- if (distance == 1) {
- for (i in 0 until size) {
- if (Pathfinder.isStandingIn(e.x - 1, e.y + i, 1, 1, x, y, victim.size(), victim.size())) {
- return true
- }
- if (Pathfinder.isStandingIn(e.x + size, e.y + i, 1, 1, x, y, victim.size(), victim.size())) {
- return true
- }
- if (Pathfinder.isStandingIn(e.x + i, e.y - 1, 1, 1, x, y, victim.size(), victim.size())) {
- return true
- }
- if (Pathfinder.isStandingIn(e.x + i, e.y + size, 1, 1, x, y, victim.size(), victim.size())) {
- return true
- }
- }
- if (e == victim.location) {
- return true
- }
- return victim.getSwingHandler(false).type == CombatStyle.MELEE && e.withinDistance(victim.location, 1) && victim.properties.combatPulse.getVictim() === entity && entity.index < victim.index
- }
- return entity.centerLocation.withinDistance(victim.centerLocation, distance + (size shr 1) + (victim.size() shr 1))
+ return CombatReach.canMelee(entity, victim, distance)
}
}
}
diff --git a/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt
index 5eeed5f21..f049145ec 100644
--- a/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt
+++ b/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt
@@ -53,10 +53,10 @@ open class RangeSwingHandler (vararg flags: SwingHandlerFlag) : CombatSwingHandl
distance = 10
}
}
- var goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, distance))
+ var goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, distance))
var type = InteractionType.STILL_INTERACT
if (victim.walkingQueue.isMoving && !goodRange) {
- goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, ++distance))
+ goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, ++distance))
type = InteractionType.MOVE_INTERACT
}
if (goodRange && super.canSwing(entity, victim) != InteractionType.NO_INTERACT) {
diff --git a/Server/src/main/core/game/node/entity/impl/WalkingQueue.java b/Server/src/main/core/game/node/entity/impl/WalkingQueue.java
index fbd9d2e36..c6506ac16 100644
--- a/Server/src/main/core/game/node/entity/impl/WalkingQueue.java
+++ b/Server/src/main/core/game/node/entity/impl/WalkingQueue.java
@@ -64,6 +64,21 @@ public final class WalkingQueue {
public ArrayList routeItems = new ArrayList();
+ /**
+ * Clears the route markers created by ::drawroute.
+ */
+ private void clearRouteItems() {
+ if (!(entity instanceof Player) || routeItems.isEmpty()) {
+ return;
+ }
+ for (GroundItem item : routeItems) {
+ if (item != null) {
+ RegionManager.getRegionPlane(item.getLocation()).remove(item);
+ }
+ }
+ routeItems.clear();
+ }
+
/**
* Constructs a new {@code WalkingQueue} {@code Object}.
* @param entity The entity.
@@ -92,17 +107,9 @@ public final class WalkingQueue {
if (hasTimerActive(entity, "frozen"))
return;
Point point = walkingQueue.poll();
- boolean drawPath = entity.getAttribute("routedraw", false);
if (point == null) {
updateRunEnergy(false);
- if (isPlayer && drawPath) {
- for (GroundItem item : routeItems) {
- if (item != null) {
- RegionManager.getRegionPlane(item.getLocation()).remove(item);
- }
- }
- routeItems.clear();
- }
+ clearRouteItems();
return;
}
if (isPlayer && ((Player) entity).getSettings().getRunEnergy() < 1.0) {
@@ -236,6 +243,7 @@ public final class WalkingQueue {
*/
public boolean updateTeleport() {
if (entity.getProperties().getTeleportLocation() != null) {
+ entity.getProperties().getCombatPulse().stop();
reset(false);
entity.setLocation(entity.getProperties().getTeleportLocation());
entity.getProperties().setTeleportLocation(null);
@@ -374,13 +382,19 @@ public final class WalkingQueue {
}
return running;
}
-
+
/**
* Checks if the entity has a path to walk.
+ *
* @return {@code True} if so.
*/
public boolean hasPath() {
- return !walkingQueue.isEmpty();
+ for (Point point : walkingQueue) {
+ if (point.getDirection() != null) {
+ return true;
+ }
+ }
+ return false;
}
/**
@@ -412,6 +426,7 @@ public final class WalkingQueue {
);
}
+ clearRouteItems();
walkingQueue.clear();
walkingQueue.add(new Point(loc.getX(), loc.getY()));
this.running = running;
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..426deb8df 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,130 @@ 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()))));
+ boolean usedSidestep = false;
+ for (int steps = 0; !current.equals(destination) && steps < maxSteps; steps++) {
+ Direction direction = Direction.getDirection(current, destination);
+ Direction stepDirection = chooseRandomMovementStep(current, direction, usedSidestep);
+ if (stepDirection == null) {
+ path.setSuccesful(false);
+ path.setMoveNear(!path.getPoints().isEmpty());
+ break;
+ }
+ Location next = current.transform(stepDirection);
+ if (!isWithinRandomMovementBounds(next)) {
+ path.setSuccesful(false);
+ path.setMoveNear(!path.getPoints().isEmpty());
+ break;
+ }
+ if (stepDirection != direction) {
+ usedSidestep = true;
+ }
+ path.getPoints().add(new Point(next.getX(), next.getY(), stepDirection, stepDirection.getStepX(), stepDirection.getStepY()));
+ current = next;
+ }
+ if (!current.equals(destination) && !path.getPoints().isEmpty()) {
+ path.setMoveNear(true);
+ }
+ return path;
+ }
+
+ private Direction chooseRandomMovementStep(Location current, Direction direction, boolean usedSidestep) {
+ if (direction == null) {
+ return null;
+ }
+ Location next = current.transform(direction);
+ if (isWithinRandomMovementBounds(next) && canTakeRandomMovementStep(current, direction)) {
+ return direction;
+ }
+ if (usedSidestep) {
+ return null;
+ }
+ for (Direction sidestep : sidestepDirections(direction)) {
+ next = current.transform(sidestep);
+ if (isWithinRandomMovementBounds(next) && canTakeRandomMovementStep(current, sidestep)) {
+ return sidestep;
+ }
+ }
+ return null;
+ }
+
+ private Direction[] sidestepDirections(Direction direction) {
+ switch (direction) {
+ case NORTH:
+ case SOUTH:
+ return new Direction[] { Direction.EAST, Direction.WEST };
+ case EAST:
+ case WEST:
+ return new Direction[] { Direction.NORTH, Direction.SOUTH };
+ case NORTH_EAST:
+ return new Direction[] { Direction.EAST, Direction.NORTH };
+ case SOUTH_EAST:
+ return new Direction[] { Direction.EAST, Direction.SOUTH };
+ case SOUTH_WEST:
+ return new Direction[] { Direction.WEST, Direction.SOUTH };
+ case NORTH_WEST:
+ return new Direction[] { Direction.WEST, Direction.NORTH };
+ default:
+ return new Direction[0];
+ }
+ }
+
+ 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/main/core/game/node/entity/npc/NPCBehavior.kt b/Server/src/main/core/game/node/entity/npc/NPCBehavior.kt
index e4fa6676b..cc54b0ab0 100644
--- a/Server/src/main/core/game/node/entity/npc/NPCBehavior.kt
+++ b/Server/src/main/core/game/node/entity/npc/NPCBehavior.kt
@@ -3,7 +3,6 @@ package core.game.node.entity.npc
import core.game.node.item.Item
import core.api.ContentInterface
import core.game.node.entity.Entity
-import core.game.world.map.RegionManager
import core.game.node.entity.player.Player
import core.game.node.entity.combat.BattleState
import core.game.node.entity.combat.CombatStyle
@@ -24,12 +23,6 @@ open class NPCBehavior(vararg val ids: Int = intArrayOf()) : ContentInterface {
}
}
- object StandardClipMaskSupplier : ClipMaskSupplier {
- override fun getClippingFlag (z: Int, x: Int, y: Int) : Int {
- return RegionManager.getClippingFlag(z,x,y)
- }
- }
-
/**
* Called every tick, before the base NPC tick() method.
* @param self the NPC instance this behavior belongs to
@@ -144,7 +137,7 @@ open class NPCBehavior(vararg val ids: Int = intArrayOf()) : ContentInterface {
* Called by pathfinding code to determine the clipping mask supplier this NPC should use. You can use this to ignore water, etc.
*/
open fun getClippingSupplier(self: NPC) : ClipMaskSupplier? {
- return StandardClipMaskSupplier
+ return null
}
/**
diff --git a/Server/src/main/core/game/system/config/ServerConfigParser.kt b/Server/src/main/core/game/system/config/ServerConfigParser.kt
index 58e3e6ebf..27efac098 100644
--- a/Server/src/main/core/game/system/config/ServerConfigParser.kt
+++ b/Server/src/main/core/game/system/config/ServerConfigParser.kt
@@ -88,7 +88,6 @@ object ServerConfigParser {
wild_pvp_enabled = data.getBoolean("world.wild_pvp_enabled"),
jad_practice_enabled = data.getBoolean("world.jad_practice_enabled"),
ge_announcement_limit = data.getLong("world.ge_announcement_limit", 500L).toInt(),
- smartpathfinder_bfs = data.getBoolean("world.smartpathfinder_bfs", false),
enable_castle_wars = data.getBoolean("world.enable_castle_wars", false),
message_model = data.getString("world.motw_identifier").toInt(),
message_string = data.getString("world.motw_text").replace("@name", ServerConstants.SERVER_NAME)
diff --git a/Server/src/main/core/game/world/GameSettings.kt b/Server/src/main/core/game/world/GameSettings.kt
index e113dbbe3..806977653 100644
--- a/Server/src/main/core/game/world/GameSettings.kt
+++ b/Server/src/main/core/game/world/GameSettings.kt
@@ -81,7 +81,6 @@ class GameSettings
var wild_pvp_enabled: Boolean,
var jad_practice_enabled: Boolean,
var ge_announcement_limit: Int,
- var smartpathfinder_bfs: Boolean,
var enable_castle_wars: Boolean,
/**"Lobby" interface
@@ -135,7 +134,6 @@ class GameSettings
val wild_pvp_enabled = if(data.containsKey("wild_pvp_enabled")) data["wild_pvp_enabled"] as Boolean else true
val jad_practice_enabled = if(data.containsKey("jad_practice_enabled")) data["jad_practice_enabled"] as Boolean else true
val ge_announcement_limit = data["ge_announcement_limit"].toString().toInt()
- val smartpathfinder_bfs = if(data.containsKey("smartpathfinder_bfs")) data["smartpathfinder_bfs"] as Boolean else false
val enable_castle_wars = if(data.containsKey("enable_castle_wars")) data["enable_castle_wars"] as Boolean else false
val allow_token_purchase = data["allow_token_purchase"] as Boolean
val message_of_the_week_identifier = data["message_of_the_week_identifier"].toString().toInt()
@@ -165,7 +163,6 @@ class GameSettings
wild_pvp_enabled,
jad_practice_enabled,
ge_announcement_limit,
- smartpathfinder_bfs,
enable_castle_wars,
message_of_the_week_identifier,
message_of_the_week_text
diff --git a/Server/src/main/core/game/world/map/RegionManager.kt b/Server/src/main/core/game/world/map/RegionManager.kt
index 9fb7b848b..7d859b8f6 100644
--- a/Server/src/main/core/game/world/map/RegionManager.kt
+++ b/Server/src/main/core/game/world/map/RegionManager.kt
@@ -6,10 +6,12 @@ import core.game.node.entity.Entity
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.node.scenery.Scenery
+import core.game.world.GameWorld
import core.game.world.map.zone.ZoneBorders
import core.tools.Log
import core.tools.RandomFunction
import core.tools.SystemLogger
+import org.rsmod.game.pathfinder.collision.CollisionFlagMap
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.concurrent.locks.ReentrantLock
@@ -24,8 +26,11 @@ object RegionManager {
* The region cache mapping.
*/
private val REGION_CACHE: MutableMap = HashMap()
+ private val RSMOD_PROJECTILE_REGIONS = HashSet()
@JvmStatic val CLIPPING_FLAGS = HashMap>()
@JvmStatic val PROJECTILE_FLAGS = HashMap>()
+ @JvmStatic val RSMOD_CLIPPING_FLAGS = CollisionFlagMap()
+ @JvmStatic val RSMOD_PROJECTILE_FLAGS = CollisionFlagMap()
public val LOCK = ReentrantLock()
@@ -125,16 +130,89 @@ object RegionManager {
@JvmStatic
fun getFlags(regionId: Int, projectile: Boolean) : Array {
- return if (projectile)
- PROJECTILE_FLAGS.getOrPut (regionId) {Array(16384){0}}
- else
+ return if (projectile) {
+ initialiseRsmodProjectileRegion(regionId)
+ PROJECTILE_FLAGS.getOrPut(regionId) { Array(16384) { 0 } }
+ } else {
CLIPPING_FLAGS.getOrPut (regionId) {Array(16384){-1}}
+ }
}
@JvmStatic
fun resetFlags(regionId: Int) {
PROJECTILE_FLAGS.put (regionId, Array(16384){0})
CLIPPING_FLAGS.put (regionId, Array(16384){-1})
+ resetRsmodFlags(regionId)
+ }
+
+ @JvmStatic
+ fun setRsmodFlag(z: Int, x: Int, y: Int, projectile: Boolean, flag: Int) {
+ val flags = if (projectile) RSMOD_PROJECTILE_FLAGS else RSMOD_CLIPPING_FLAGS
+ flags[x, y, z] = flag
+ }
+
+ @JvmStatic
+ fun loadClippingWindow(center: Location, size: Int) {
+ val ensured = ENSURED_WINDOW_REGIONS.get()
+ if (ensured.tick != GameWorld.ticks) {
+ ensured.regions.clear()
+ ensured.tick = GameWorld.ticks
+ }
+ val minX = center.x - (size / 2)
+ val minY = center.y - (size / 2)
+ val maxX = minX + size - 1
+ val maxY = minY + size - 1
+ for (regionX in (minX shr 6)..(maxX shr 6)) {
+ for (regionY in (minY shr 6)..(maxY shr 6)) {
+ val regionId = (regionX shl 8) or regionY
+ if (!ensured.regions.add(regionId)) {
+ continue
+ }
+ Region.load(forId(regionId))
+ initialiseRsmodProjectileRegion(regionId)
+ }
+ }
+ }
+
+ /**
+ * Pathfinding probes load the same clipping window many times per tick; regions
+ * cannot transition to unloaded between probes within one tick, so each thread only
+ * needs to ensure a region once per tick instead of taking the region lock per probe.
+ */
+ private class EnsuredWindowRegions {
+ var tick = -1
+ val regions = HashSet()
+ }
+
+ private val ENSURED_WINDOW_REGIONS = ThreadLocal.withInitial { EnsuredWindowRegions() }
+
+ private fun resetRsmodFlags(regionId: Int) {
+ val baseX = (regionId shr 8) shl 6
+ val baseY = (regionId and 0xFF) shl 6
+ for (z in 0 until 4) {
+ for (x in baseX until baseX + 64 step 8) {
+ for (y in baseY until baseY + 64 step 8) {
+ RSMOD_CLIPPING_FLAGS.deallocateIfPresent(x, y, z)
+ val projectileFlags = RSMOD_PROJECTILE_FLAGS.allocateIfAbsent(x, y, z)
+ projectileFlags.fill(0)
+ }
+ }
+ }
+ }
+
+ private fun initialiseRsmodProjectileRegion(regionId: Int) {
+ if (!RSMOD_PROJECTILE_REGIONS.add(regionId)) {
+ return
+ }
+ val baseX = (regionId shr 8) shl 6
+ val baseY = (regionId and 0xFF) shl 6
+ for (z in 0 until 4) {
+ for (x in baseX until baseX + 64 step 8) {
+ for (y in baseY until baseY + 64 step 8) {
+ RSMOD_PROJECTILE_FLAGS.allocateIfAbsent(x, y, z).fill(0)
+ }
+ }
+ }
}
/**
diff --git a/Server/src/main/core/game/world/map/build/RegionFlags.java b/Server/src/main/core/game/world/map/build/RegionFlags.java
index 177da33c9..671935213 100644
--- a/Server/src/main/core/game/world/map/build/RegionFlags.java
+++ b/Server/src/main/core/game/world/map/build/RegionFlags.java
@@ -179,7 +179,9 @@ public final class RegionFlags {
public void addFlag(int x, int y, int clipdata) {
int current = getFlag(x, y);
Pair indices = getFlagIndex(x, y);
- RegionManager.getFlags(indices.getFirst(), projectile)[indices.getSecond()] = max(0, current) | clipdata;
+ int updated = max(0, current) | clipdata;
+ RegionManager.getFlags(indices.getFirst(), projectile)[indices.getSecond()] = updated;
+ RegionManager.setRsmodFlag(plane, baseX + x, baseY + y, projectile, updated);
}
public void removeFlag(int x, int y, int clipdata) {
@@ -189,16 +191,19 @@ public final class RegionFlags {
current = max(0, current) & ~clipdata;
RegionManager.getFlags(indices.getFirst(), projectile)[indices.getSecond()] = current;
+ RegionManager.setRsmodFlag(plane, baseX + x, baseY + y, projectile, current);
}
public void clearFlag(int x, int y) {
Pair indices = getFlagIndex(x, y);
RegionManager.getFlags(indices.getFirst(), projectile)[indices.getSecond()] = 0;
+ RegionManager.setRsmodFlag(plane, baseX + x, baseY + y, projectile, 0);
}
public void invalidateFlag(int x, int y) {
Pair indices = getFlagIndex(x, y);
RegionManager.getFlags(indices.getFirst(), projectile)[indices.getSecond()] = -1;
+ RegionManager.setRsmodFlag(plane, baseX + x, baseY + y, projectile, -1);
}
/**
@@ -534,4 +539,4 @@ public final class RegionFlags {
public void setLandscape(boolean[][] landscape) {
this.landscape = landscape;
}
-}
\ No newline at end of file
+}
diff --git a/Server/src/main/core/game/world/map/path/DumbPathfinder.java b/Server/src/main/core/game/world/map/path/DumbPathfinder.java
index 8bcfb32e0..66e4eef1e 100644
--- a/Server/src/main/core/game/world/map/path/DumbPathfinder.java
+++ b/Server/src/main/core/game/world/map/path/DumbPathfinder.java
@@ -3,39 +3,35 @@ package core.game.world.map.path;
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 java.util.ArrayList;
import java.util.List;
/**
- * A pathfinder implementation used for an easy path, where the pathfinder won't
- * find a way around clipped objects..
This is used for NPC combat
- * following, NPC random movement, etc.
- * @author Emperor
+ * Pathfinder for simple local movement. It walks directly toward the
+ * destination and only tries the horizontal/vertical alternatives of a blocked
+ * diagonal step; it does not search around obstacles.
*/
public final class DumbPathfinder extends Pathfinder {
- /**
- * If a path can be found.
- */
+
private boolean found;
-
- /**
- * The plane.
- */
private int z;
-
- /**
- * The x-coordinate.
- */
private int x;
-
- /**
- * The y-coordinate.
- */
private int y;
-
+
@Override
- public Path find(Location start, int size, Location end, int sizeX, int sizeY, int rotation, int type, int walkingFlag, boolean near, ClipMaskSupplier clipMaskSupplier) {
+ public Path find(Location start,
+ int size,
+ Location end,
+ int sizeX,
+ int sizeY,
+ int rotation,
+ int type,
+ int walkingFlag,
+ boolean near,
+ ClipMaskSupplier clipMaskSupplier) {
+ ClipMaskSupplier supplier = clipMaskSupplier != null ? clipMaskSupplier : RegionManager::getClippingFlag;
Path path = new Path();
z = start.getZ();
x = start.getX();
@@ -44,20 +40,19 @@ public final class DumbPathfinder extends Pathfinder {
path.setSuccesful(true);
while (x != end.getX() || y != end.getY()) {
Direction[] directions = getDirection(x, y, end);
- if (type != 0) {
- if ((type < 5 || type == 10) && canDoorInteract(x, y, size, end.getX(), end.getY(), type - 1, rotation, z, clipMaskSupplier)) {
+ if (type >= 0) {
+ if ((type < 5 || type == 9) && canDoorInteract(x, y, size, end.getX(), end.getY(), type, rotation, z, supplier)) {
break;
}
- if (type < 10 && canDecorationInteract(x, y, size, end.getX(), end.getY(), type - 1, rotation, z, clipMaskSupplier)) {
+ if (type < 10 && canDecorationInteract(x, y, size, end.getX(), end.getY(), rotation, type, z, supplier)) {
break;
}
}
if (sizeX != 0 && sizeY != 0) {
- if (canInteract(x, y, size, end.getX(), end.getY(), sizeX, sizeY, walkingFlag, z, clipMaskSupplier)) {
+ if (canInteract(x, y, size, end.getX(), end.getY(), sizeX, sizeY, walkingFlag, z, supplier)) {
break;
}
- if (directions.length > 1) { // Ensures we approach the location
- // correctly (non-diagonal).
+ if (directions.length > 1) {
Direction dir = directions[0];
if (x + dir.getStepX() == end.getX() && y + dir.getStepY() == end.getY()) {
directions[0] = directions[directions.length - 1];
@@ -67,11 +62,11 @@ public final class DumbPathfinder extends Pathfinder {
}
found = true;
if (size < 2) {
- checkSingleTraversal(points, clipMaskSupplier, directions);
+ checkSingleTraversal(points, supplier, directions);
} else if (size == 2) {
- checkDoubleTraversal(points, clipMaskSupplier, directions);
+ checkDoubleTraversal(points, supplier, directions);
} else {
- checkVariableTraversal(points, directions, size, clipMaskSupplier);
+ checkVariableTraversal(points, directions, size, supplier);
}
if (!found) {
path.setMoveNear(x != start.getX() || y != start.getY());
@@ -79,22 +74,10 @@ public final class DumbPathfinder extends Pathfinder {
break;
}
}
- if (!points.isEmpty()) {
- Direction last = null;
- for (int i = 0; i < points.size() - 1; i++) {
- Point p = points.get(i);
- path.getPoints().add(p);
- }
- path.getPoints().add(points.get(points.size() - 1));
- }
+ path.getPoints().addAll(points);
return path;
}
-
- /**
- * Checks traversal for a size 1 entity.
- * @param points The points list.
- * @param directions The directions.
- */
+
private void checkSingleTraversal(List points, ClipMaskSupplier clipMaskSupplier, Direction... directions) {
for (Direction dir : directions) {
found = true;
@@ -108,7 +91,12 @@ public final class DumbPathfinder extends Pathfinder {
y++;
break;
case NORTH_EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + 1, y) & PREVENT_EAST) != 0 || (clipMaskSupplier.getClippingFlag(z, x, y + 1) & PREVENT_NORTH) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 1, y + 1) & PREVENT_NORTHEAST) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + 1, y) & PREVENT_EAST) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x,
+ y + 1) & PREVENT_NORTH) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x + 1,
+ y + 1) & PREVENT_NORTHEAST) != 0) {
found = false;
break;
}
@@ -125,7 +113,12 @@ public final class DumbPathfinder extends Pathfinder {
x++;
break;
case SOUTH_EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + 1, y) & PREVENT_EAST) != 0 || (clipMaskSupplier.getClippingFlag(z, x, y - 1) & PREVENT_SOUTH) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 1, y - 1) & PREVENT_SOUTHEAST) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + 1, y) & PREVENT_EAST) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x,
+ y - 1) & PREVENT_SOUTH) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x + 1,
+ y - 1) & PREVENT_SOUTHEAST) != 0) {
found = false;
break;
}
@@ -142,7 +135,12 @@ public final class DumbPathfinder extends Pathfinder {
y--;
break;
case SOUTH_WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & PREVENT_WEST) != 0 || (clipMaskSupplier.getClippingFlag(z, x, y - 1) & PREVENT_SOUTH) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y - 1) & PREVENT_SOUTHWEST) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & PREVENT_WEST) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x,
+ y - 1) & PREVENT_SOUTH) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x - 1,
+ y - 1) & PREVENT_SOUTHWEST) != 0) {
found = false;
break;
}
@@ -159,7 +157,12 @@ public final class DumbPathfinder extends Pathfinder {
x--;
break;
case NORTH_WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & PREVENT_WEST) != 0 || (clipMaskSupplier.getClippingFlag(z, x, y + 1) & PREVENT_NORTH) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y + 1) & PREVENT_NORTHWEST) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & PREVENT_WEST) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x,
+ y + 1) & PREVENT_NORTH) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x - 1,
+ y + 1) & PREVENT_NORTHWEST) != 0) {
found = false;
break;
}
@@ -173,18 +176,15 @@ public final class DumbPathfinder extends Pathfinder {
}
}
}
-
- /**
- * Checks traversal for a size 1 entity.
- * @param points The points list.
- * @param directions The directions.
- */
+
private void checkDoubleTraversal(List points, ClipMaskSupplier clipMaskSupplier, Direction... directions) {
for (Direction dir : directions) {
found = true;
switch (dir) {
case NORTH:
- if ((clipMaskSupplier.getClippingFlag(z, x, y + 2) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 1, y + 2) & 0x12c01e0) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x, y + 2) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + 1,
+ y + 2) & 0x12c01e0) != 0) {
found = false;
break;
}
@@ -192,7 +192,12 @@ public final class DumbPathfinder extends Pathfinder {
y++;
break;
case NORTH_EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + 1, y + 2) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 2, y + 2) & 0x12c01e0) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 2, y + 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + 1, y + 2) & 0x12c01f8) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + 2,
+ y + 2) & 0x12c01e0) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x + 2,
+ y + 1) & 0x12c01e3) != 0) {
found = false;
break;
}
@@ -201,7 +206,9 @@ public final class DumbPathfinder extends Pathfinder {
y++;
break;
case EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + 2, y) & 0x12c0183) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 2, y + 1) & 0x12c01e0) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + 2, y) & 0x12c0183) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + 2,
+ y + 1) & 0x12c01e0) != 0) {
found = false;
break;
}
@@ -209,7 +216,12 @@ public final class DumbPathfinder extends Pathfinder {
x++;
break;
case SOUTH_EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + 1, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 2, y) & 0x12c01e0) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 2, y - 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + 1, y - 1) & 0x12c018f) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + 2,
+ y) & 0x12c01e3) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x + 2,
+ y - 1) & 0x12c0183) != 0) {
found = false;
break;
}
@@ -218,7 +230,9 @@ public final class DumbPathfinder extends Pathfinder {
y--;
break;
case SOUTH:
- if ((clipMaskSupplier.getClippingFlag(z, x, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x + 1, y - 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + 1,
+ y - 1) & 0x12c0183) != 0) {
found = false;
break;
}
@@ -226,7 +240,12 @@ public final class DumbPathfinder extends Pathfinder {
y--;
break;
case SOUTH_WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x, y - 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x - 1,
+ y) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x,
+ y - 1) & 0x12c018f) != 0) {
found = false;
break;
}
@@ -235,7 +254,9 @@ public final class DumbPathfinder extends Pathfinder {
y--;
break;
case WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y + 1) & 0x12c0138) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x - 1,
+ y + 1) & 0x12c0138) != 0) {
found = false;
break;
}
@@ -243,7 +264,12 @@ public final class DumbPathfinder extends Pathfinder {
x--;
break;
case NORTH_WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y + 2) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x, y + 2) & 0x12c01e0) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + 1) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x - 1,
+ y + 2) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x,
+ y + 2) & 0x12c01f8) != 0) {
found = false;
break;
}
@@ -257,19 +283,16 @@ public final class DumbPathfinder extends Pathfinder {
}
}
}
-
- /**
- * Checks traversal for variable size entities.
- * @param points The points list.
- * @param directions The directions to check.
- * @param size The mover size.
- */
+
private void checkVariableTraversal(List points, Direction[] directions, int size, ClipMaskSupplier clipMaskSupplier) {
for (Direction dir : directions) {
found = true;
- roar: switch (dir) {
+ roar:
+ switch (dir) {
case NORTH:
- if ((clipMaskSupplier.getClippingFlag(z, x, y + size) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x + (size - 1), y + size) & 0x12c01e0) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x, y + size) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + (size - 1),
+ y + size) & 0x12c01e0) != 0) {
found = false;
break;
}
@@ -283,12 +306,19 @@ public final class DumbPathfinder extends Pathfinder {
y++;
break;
case NORTH_EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + 1, y + size) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x + size, y + size) & 0x12c01e0) != 0 || (clipMaskSupplier.getClippingFlag(z, x + size, y + 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + 1, y + size) & 0x12c01f8) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + size,
+ y + size) & 0x12c01e0) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x + size,
+ y + 1) & 0x12c01e3) != 0) {
found = false;
break;
}
for (int i = 1; i < size - 1; i++) {
- if ((clipMaskSupplier.getClippingFlag(z, x + (i + 1), y + size) & 0x12c01f8) != 0 || (clipMaskSupplier.getClippingFlag(z, x + size, y + (i + 1)) & 0x12c01e3) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + (i + 1), y + size) & 0x12c01f8) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + size,
+ y + (i + 1)) & 0x12c01e3) != 0) {
found = false;
break roar;
}
@@ -298,7 +328,9 @@ public final class DumbPathfinder extends Pathfinder {
y++;
break;
case EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + size, y) & 0x12c0183) != 0 || (clipMaskSupplier.getClippingFlag(z, x + size, y + (size - 1)) & 0x12c01e0) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + size, y) & 0x12c0183) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + size,
+ y + (size - 1)) & 0x12c01e0) != 0) {
found = false;
break;
}
@@ -312,12 +344,19 @@ public final class DumbPathfinder extends Pathfinder {
x++;
break;
case SOUTH_EAST:
- if ((clipMaskSupplier.getClippingFlag(z, x + 1, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x + size, y + (size - 2)) & 0x12c01e0) != 0 || (clipMaskSupplier.getClippingFlag(z, x + size, y - 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + 1, y - 1) & 0x12c018f) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + size,
+ y + (size - 2)) & 0x12c01e3) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x + size,
+ y - 1) & 0x12c0183) != 0) {
found = false;
break;
}
for (int i = 1; i < size - 1; i++) {
- if ((clipMaskSupplier.getClippingFlag(z, x + size, y + (i - 1)) & 0x12c01e3) != 0 || (clipMaskSupplier.getClippingFlag(z, x + (i + 1), y - 1) & 0x12c018f) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x + size, y + (i - 1)) & 0x12c01e3) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + (i + 1),
+ y - 1) & 0x12c018f) != 0) {
found = false;
break roar;
}
@@ -327,7 +366,9 @@ public final class DumbPathfinder extends Pathfinder {
y--;
break;
case SOUTH:
- if ((clipMaskSupplier.getClippingFlag(z, x, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x + (size - 1), y - 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + (size - 1),
+ y - 1) & 0x12c0183) != 0) {
found = false;
break;
}
@@ -341,12 +382,19 @@ public final class DumbPathfinder extends Pathfinder {
y--;
break;
case SOUTH_WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + (size - 2)) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x + (size - 2), y - 1) & 0x12c0183) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + (size - 2)) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x - 1,
+ y - 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x + (size - 2),
+ y - 1) & 0x12c018f) != 0) {
found = false;
break;
}
for (int i = 1; i < size - 1; i++) {
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + (i - 1)) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(z, x + (i - 1), y - 1) & 0x12c018f) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + (i - 1)) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + (i - 1),
+ y - 1) & 0x12c018f) != 0) {
found = false;
break roar;
}
@@ -356,7 +404,9 @@ public final class DumbPathfinder extends Pathfinder {
y--;
break;
case WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y + (size - 1)) & 0x12c0138) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x - 1,
+ y + (size - 1)) & 0x12c0138) != 0) {
found = false;
break;
}
@@ -370,12 +420,19 @@ public final class DumbPathfinder extends Pathfinder {
x--;
break;
case NORTH_WEST:
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + 1) & 0x12c010e) != 0 || (clipMaskSupplier.getClippingFlag(z, x - 1, y + size) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(z, x, y + size) & 0x12c01e0) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + 1) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x - 1,
+ y + size) & 0x12c0138) != 0 || (clipMaskSupplier.getClippingFlag(
+ z,
+ x,
+ y + size) & 0x12c01f8) != 0) {
found = false;
break;
}
for (int i = 1; i < size - 1; i++) {
- if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + (i + 1)) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(z, x + (i - 1), y + size) & 0x12c01f8) != 0) {
+ if ((clipMaskSupplier.getClippingFlag(z, x - 1, y + (i + 1)) & 0x12c013e) != 0 || (clipMaskSupplier.getClippingFlag(z,
+ x + i,
+ y + size) & 0x12c01f8) != 0) {
found = false;
break roar;
}
@@ -390,38 +447,32 @@ public final class DumbPathfinder extends Pathfinder {
}
}
}
-
- /**
- * Gets the direction.
- * @param end The end direction.
- * @return The direction.
- */
+
private static Direction[] getDirection(int startX, int startY, Location end) {
int endX = end.getX();
int endY = end.getY();
if (startX == endX) {
if (startY > endY) {
- return new Direction[] { Direction.SOUTH };
+ return new Direction[]{Direction.SOUTH};
} else if (startY < endY) {
- return new Direction[] { Direction.NORTH };
+ return new Direction[]{Direction.NORTH};
}
} else if (startY == endY) {
if (startX > endX) {
- return new Direction[] { Direction.WEST };
+ return new Direction[]{Direction.WEST};
}
- return new Direction[] { Direction.EAST };
+ return new Direction[]{Direction.EAST};
} else {
if (startX < endX && startY < endY) {
- return new Direction[] { Direction.NORTH_EAST, Direction.EAST, Direction.NORTH };
+ return new Direction[]{Direction.NORTH_EAST, Direction.EAST, Direction.NORTH};
} else if (startX < endX && startY > endY) {
- return new Direction[] { Direction.SOUTH_EAST, Direction.EAST, Direction.SOUTH };
+ return new Direction[]{Direction.SOUTH_EAST, Direction.EAST, Direction.SOUTH};
} else if (startX > endX && startY < endY) {
- return new Direction[] { Direction.NORTH_WEST, Direction.WEST, Direction.NORTH };
+ return new Direction[]{Direction.NORTH_WEST, Direction.WEST, Direction.NORTH};
} else if (startX > endX && startY > endY) {
- return new Direction[] { Direction.SOUTH_WEST, Direction.WEST, Direction.SOUTH };
+ return new Direction[]{Direction.SOUTH_WEST, Direction.WEST, Direction.SOUTH};
}
}
return new Direction[0];
}
-
}
diff --git a/Server/src/main/core/game/world/map/path/Pathfinder.java b/Server/src/main/core/game/world/map/path/Pathfinder.java
index 9fce3a782..fae06fd1f 100644
--- a/Server/src/main/core/game/world/map/path/Pathfinder.java
+++ b/Server/src/main/core/game/world/map/path/Pathfinder.java
@@ -5,11 +5,11 @@ import core.game.node.entity.Entity;
import core.game.node.entity.npc.NPC;
import core.game.node.item.GroundItem;
import core.game.node.scenery.Scenery;
-import core.game.world.map.Direction;
import core.game.world.map.Location;
import core.game.world.map.RegionManager;
public abstract class Pathfinder {
+
public static final int PREVENT_NORTH = 0x12c0120;
public static final int PREVENT_EAST = 0x12c0180;
public static final int PREVENT_NORTHEAST = 0x12c01e0;
@@ -18,182 +18,140 @@ public abstract class Pathfinder {
public static final int PREVENT_WEST = 0x12c0108;
public static final int PREVENT_SOUTHWEST = 0x12c010e;
public static final int PREVENT_NORTHWEST = 0x12c0138;
-
-
+
/**
* The smart path finder.
*/
- public static final SmartPathfinder SMART = new SmartPathfinder();
-
+ public static final Pathfinder SMART = new RsmodPathfinder();
+
/**
* The dumb path finder.
*/
- public static final DumbPathfinder DUMB = new DumbPathfinder();
-
+ public static final Pathfinder DUMB = new DumbPathfinder();
+
/**
* The projectile path finder.
*/
- public static final ProjectilePathfinder PROJECTILE = new ProjectilePathfinder();
-
- /**
- * The south direction flag.
- */
- public static final int SOUTH_FLAG = 0x1;
-
- /**
- * The west direction flag.
- */
- public static final int WEST_FLAG = 0x2;
-
- /**
- * The north direction flag.
- */
- public static final int NORTH_FLAG = 0x4;
-
- /**
- * The east direction flag.
- */
- public static final int EAST_FLAG = 0x8;
-
- /**
- * The south-west direction flag.
- */
- public static final int SOUTH_WEST_FLAG = SOUTH_FLAG | WEST_FLAG;
-
- /**
- * The north-west direction flag.
- */
- public static final int NORTH_WEST_FLAG = NORTH_FLAG | WEST_FLAG;
-
- /**
- * The south-east direction flag.
- */
- public static final int SOUTH_EAST_FLAG = SOUTH_FLAG | EAST_FLAG;
-
- /**
- * The north-east direction flag.
- */
- public static final int NORTH_EAST_FLAG = NORTH_FLAG | EAST_FLAG;
-
- public static int flagForDirection(Direction d) {
- switch(d) {
- case NORTH_WEST: return NORTH_WEST_FLAG;
- case NORTH: return NORTH_FLAG;
- case NORTH_EAST: return NORTH_EAST_FLAG;
- case WEST: return WEST_FLAG;
- case EAST: return EAST_FLAG;
- case SOUTH_WEST: return SOUTH_WEST_FLAG;
- case SOUTH: return SOUTH_FLAG;
- case SOUTH_EAST: return SOUTH_EAST_FLAG;
- default: return 0;
- }
- }
-
+ public static final Pathfinder PROJECTILE = new RsmodProjectilePathfinder();
+
/**
* Finds a path from the location to the end location.
- * @param location The start location.
- * @param size The mover size.
- * @param end The end location.
- * @param sizeX The x-size of the destination node.
- * @param sizeY The y-size of the destination node.
- * @param rotation The object rotation.
- * @param type The object type.
+ *
+ * @param location The start location.
+ * @param size The mover size.
+ * @param end The end location.
+ * @param sizeX The x-size of the destination node.
+ * @param sizeY The y-size of the destination node.
+ * @param rotation The object rotation.
+ * @param type The object type.
* @param walkingFlag The object walking flag.
- * @param near If we should find the nearest location if a path can't be
- * found.
+ * @param near If we should find the nearest location if a path can't be
+ * found.
* @return The path.
*/
- public abstract Path find(Location location, int size, Location end, int sizeX, int sizeY, int rotation, int type, int walkingFlag, boolean near, ClipMaskSupplier clipMaskSupplier);
-
+ public abstract Path find(Location location,
+ int size,
+ Location end,
+ int sizeX,
+ int sizeY,
+ int rotation,
+ int type,
+ int walkingFlag,
+ boolean near,
+ ClipMaskSupplier clipMaskSupplier);
+
/**
* Finds a path from the start location to the end location.
- * @param mover The moving entity.
+ *
+ * @param mover The moving entity.
* @param destination The destination node.
* @return The path.
*/
public static Path find(Entity mover, Node destination) {
return find(mover, destination, true, SMART);
}
-
+
/**
* Finds a path from the start location to the end location.
- * @param mover The moving entity.
+ *
+ * @param mover The moving entity.
* @param destination The destination node.
- * @param near If we should move near the end location, if we can't reach
- * it.
- * @param finder The pathfinder to use.
+ * @param near If we should move near the end location, if we can't reach
+ * it.
+ * @param finder The pathfinder to use.
* @return The path.
*/
public static Path find(Entity mover, Node destination, boolean near, Pathfinder finder) {
- ClipMaskSupplier cms = null;
- if (mover instanceof NPC) {
- cms = ((NPC) mover).behavior.getClippingSupplier(((NPC) mover));
- }
- if (cms == null)
- cms = RegionManager::getClippingFlag;
+ ClipMaskSupplier cms = null;
+ if (mover instanceof NPC) {
+ NPC npc = (NPC) mover;
+ cms = npc.behavior != null ? npc.behavior.getClippingSupplier(npc) : null;
+ }
return find(mover.getLocation(), mover.size(), destination, near, finder, cms);
}
-
- public static Path findWater(Entity mover, Node destination, boolean near, Pathfinder finder){
- return find(mover.getLocation(),mover.size(),destination,near,finder, RegionManager::getWaterClipFlag);
+
+ public static Path findWater(Entity mover, Node destination, boolean near, Pathfinder finder) {
+ return find(mover.getLocation(), mover.size(), destination, near, finder, RegionManager::getWaterClipFlag);
}
-
+
public static Path find(Entity mover, Node destination, boolean near, Pathfinder finder, ClipMaskSupplier clipMaskSupplier) {
return find(mover.getLocation(), mover.size(), destination, near, finder, clipMaskSupplier);
}
-
+
/**
* Finds a path from the start location to the end location.
+ *
* @param destination The destination node.
* @return The path.
*/
public static Path find(Location start, Node destination) {
return find(start, destination, true, SMART);
}
-
- public static Path find(Location start, Node destination, int moverSize) {
- return find(start, moverSize, destination, true, SMART, RegionManager::getClippingFlag);
- }
-
+
+ public static Path find(Location start, Node destination, int moverSize) {
+ return find(start, moverSize, destination, true, SMART, null);
+ }
+
/**
* Finds a path from the start location to the end location.
+ *
* @param destination The destination node.
- * @param near If we should move near the end location, if we can't reach
- * it.
- * @param finder The pathfinder to use.
+ * @param near If we should move near the end location, if we can't reach
+ * it.
+ * @param finder The pathfinder to use.
* @return The path.
*/
public static Path find(Location start, Node destination, boolean near, Pathfinder finder) {
- return find(start, 1, destination, near, finder, RegionManager::getClippingFlag);
+ return find(start, 1, destination, near, finder, null);
}
-
+
/**
* Finds a path from the start location to the end location.
+ *
* @param destination The destination node.
- * @param near If we should move near the end location, if we can't reach
- * it.
- * @param finder The pathfinder to use.
+ * @param near If we should move near the end location, if we can't reach
+ * it.
+ * @param finder The pathfinder to use.
* @return The path.
*/
public static Path find(Location start, int moverSize, Node destination, boolean near, Pathfinder finder, ClipMaskSupplier clipMaskSupplier) {
if (destination instanceof Scenery) {
- Scenery object = (Scenery) destination;
+ Scenery object = getRouteScenery((Scenery) destination);
int type = object.getType();
int rotation = object.getRotation();
if (type == 10 || type == 11 || type == 22) {
- int sizeX = object.getDefinition().sizeX;
- int sizeY = object.getDefinition().sizeY;
- if (rotation % 2 != 0) {
- sizeX = object.getDefinition().sizeY;
- sizeY = object.getDefinition().sizeX;
- }
- int walkingFlag = object.getDefinition().getWalkingFlag();
- if (rotation != 0) {
- walkingFlag = (walkingFlag << rotation & 0xf) + (walkingFlag >> 4 - rotation);
- }
- return finder.find(start, moverSize, destination.getLocation(), sizeX, sizeY, 0, 0, walkingFlag, near, clipMaskSupplier);
+ return finder.find(start,
+ moverSize,
+ object.getLocation(),
+ object.getDefinition().sizeX,
+ object.getDefinition().sizeY,
+ rotation,
+ type,
+ object.getDefinition().getWalkingFlag(),
+ near,
+ clipMaskSupplier);
}
- return finder.find(start, moverSize, destination.getLocation(), 0, 0, rotation, 1 + type, 0, near, clipMaskSupplier);
+ return finder.find(start, moverSize, object.getLocation(), 0, 0, rotation, type, 0, near, clipMaskSupplier);
}
int size = 0;
if (destination instanceof Entity) {
@@ -201,393 +159,83 @@ public abstract class Pathfinder {
} else if (destination instanceof GroundItem && !RegionManager.isTeleportPermitted(destination.getLocation())) {
size = 1;
}
- return finder.find(start, moverSize, destination.getLocation(), size, size, 0, 0, 0, near, clipMaskSupplier);
+ return finder.find(start, moverSize, destination.getLocation(), size, size, 0, -1, 0, near, clipMaskSupplier);
}
-
+
+ private static Scenery getRouteScenery(Scenery object) {
+ Scenery wrapper = object.getWrapper();
+ if (wrapper == object) {
+ return object;
+ }
+ if (getFootprintArea(wrapper) <= getFootprintArea(object)) {
+ return object;
+ }
+ return wrapper;
+ }
+
+ private static int getFootprintArea(Scenery object) {
+ return object.getDefinition().sizeX * object.getDefinition().sizeY;
+ }
+
/**
* Checks if interaction with decoration is possible.
- * @param curX The current x-coordinate in viewport.
- * @param curY The current y-coordinate in viewport.
- * @param size The mover size.
- * @param destX The destination x-coordinate in viewport.
- * @param destY The destination y-coordinate in viewport.
- * @param type The object type.
+ *
+ * @param curX The current x-coordinate in viewport.
+ * @param curY The current y-coordinate in viewport.
+ * @param size The mover size.
+ * @param destX The destination x-coordinate in viewport.
+ * @param destY The destination y-coordinate in viewport.
+ * @param type The object type.
* @param rotation The object rotation.
* @return {@code True} if so.
*/
- public static boolean canDecorationInteract(int curX, int curY, int size, int destX, int destY, int rotation, int type, int z, ClipMaskSupplier clipMaskSupplier) {
- if (size != 1) {
- if (destX >= curX && destX <= (curX + size) - 1 && destY <= (destY + size) - 1) {
- return true;
- }
- } else if (destX == curX && curY == destY) {
- return true;
- }
- if (size == 1) {
- int flag = clipMaskSupplier.getClippingFlag(z, curX, curY);
- if (type == 6 || type == 7) {
- if (type == 7) {
- rotation = rotation + 2 & 0x3;
- }
- if (rotation == 0) {
- if (curX == 1 + destX && curY == destY && (0x80 & flag) == 0) {
- return true;
- }
- if (destX == curX && curY == destY - 1 && (flag & 0x2) == 0) {
- return true;
- }
- } else if (rotation == 1) {
- if (curX == destX - 1 && curY == destY && (0x8 & flag) == 0) {
- return true;
- }
- if (curX == destX && curY == destY - 1 && (flag & 0x2) == 0) {
- return true;
- }
- } else if (rotation == 2) {
- if (destX - 1 == curX && destY == curY && (flag & 0x8) == 0) {
- return true;
- }
- if (destX == curX && destY + 1 == curY && (0x20 & flag) == 0) {
- return true;
- }
- } else if (rotation == 3) {
- if (destX + 1 == curX && curY == destY && (0x80 & flag) == 0) {
- return true;
- }
- if (destX == curX && curY == destY + 1 && (0x20 & flag) == 0) {
- return true;
- }
- }
- }
- if (type == 8) {
- if (destX == curX && curY == destY + 1 && (flag & 0x20) == 0) {
- return true;
- }
- if (destX == curX && -1 + destY == curY && (0x2 & flag) == 0) {
- return true;
- }
- if (curX == destX - 1 && curY == destY && (0x8 & flag) == 0) {
- return true;
- }
- if (curX == destX + 1 && curY == destY && (flag & 0x80) == 0) {
- return true;
- }
- }
- } else {
- int cornerX = curX + size - 1;
- int cornerY = curY + size - 1;
- if (type == 6 || type == 7) {
- if (type == 7) {
- rotation = 0x3 & 2 + rotation;
- }
- if (rotation == 0) {
- if (destX + 1 == curX && destY >= curY && destY <= cornerY && (clipMaskSupplier.getClippingFlag(z, curX, destY) & 0x80) == 0) {
- return true;
- }
- if (destX >= curX && destX <= cornerX && destY - size == curY && (0x2 & clipMaskSupplier.getClippingFlag(z, destX, cornerY)) == 0) {
- return true;
- }
- } else if (rotation == 1) {
- if (-size + destX == curX && destY >= curY && cornerY >= destY && (clipMaskSupplier.getClippingFlag(z, cornerX, destY) & 0x8) == 0) {
- return true;
- }
- if (curX <= destX && cornerX >= destX && -size + destY == curY && (clipMaskSupplier.getClippingFlag(z, destX, cornerY) & 0x2) == 0) {
- return true;
- }
- } else if (rotation == 2) {
- if (curX == destX - size && curY <= destY && destY <= cornerY && (0x8 & clipMaskSupplier.getClippingFlag(z, cornerX, destY)) == 0) {
- return true;
- }
- if (curX <= destX && cornerX >= destX && destY + 1 == curY && (0x20 & clipMaskSupplier.getClippingFlag(z, destX, curY)) == 0) {
- return true;
- }
- } else if (rotation == 3) {
- if (1 + destX == curX && curY <= destY && destY <= cornerY && (0x80 & clipMaskSupplier.getClippingFlag(z, curX, destY)) == 0) {
- return true;
- }
- if (destX >= curX && destX <= cornerX && 1 + destY == curY && (clipMaskSupplier.getClippingFlag(z, destX, curY) & 0x20) == 0) {
- return true;
- }
- }
- }
- if (type == 8) {
- if (curX <= destX && destX <= cornerX && 1 + destY == curY && (clipMaskSupplier.getClippingFlag(z, destX, curY) & 0x20) == 0) {
- return true;
- }
- if (curX <= destX && destX <= cornerX && curY == -size + destY && (0x2 & clipMaskSupplier.getClippingFlag(z, destX, cornerY)) == 0) {
- return true;
- }
- if (curX == -size + destX && destY >= curY && destY <= cornerY && (0x8 & clipMaskSupplier.getClippingFlag(z, cornerX, destY)) == 0) {
- return true;
- }
- if (1 + destX == curX && curY <= destY && cornerY >= destY && (clipMaskSupplier.getClippingFlag(z, curX, destY) & 0x80) == 0) {
- return true;
- }
- }
- }
- return false;
+ public static boolean canDecorationInteract(int curX,
+ int curY,
+ int size,
+ int destX,
+ int destY,
+ int rotation,
+ int type,
+ int z,
+ ClipMaskSupplier clipMaskSupplier) {
+ return RsmodPathfinder.canReach(curX, curY, size, destX, destY, 1, 1, rotation, type, 0, z, clipMaskSupplier);
}
-
+
/**
* Checks if interaction with a door is possible.
- * @param curX The current x-coordinate in viewport.
- * @param curY The current y-coordinate in viewport.
- * @param size The mover size.
- * @param destX The destination x-coordinate in viewport.
- * @param destY The destination y-coordinate in viewport.
- * @param type The object type.
+ *
+ * @param curX The current x-coordinate in viewport.
+ * @param curY The current y-coordinate in viewport.
+ * @param size The mover size.
+ * @param destX The destination x-coordinate in viewport.
+ * @param destY The destination y-coordinate in viewport.
+ * @param type The object type.
* @param rotation The object rotation.
* @return {@code True} if so.
*/
- public static boolean canDoorInteract(int curX, int curY, int size, int destX, int destY, int type, int rotation, int z, ClipMaskSupplier clipMaskSupplier) {
- if (size != 1) {
- if (destX >= curX && destX <= size + curX - 1 && destY <= destY + size - 1) {
- return true;
- }
- } else if (curX == destX && destY == curY) {
- return true;
- }
-
- if (size == 1) {
- if (type == 0) {
- if (rotation == 0) {
- if (curX == destX - 1 && destY == curY) {
- return true;
- }
- if (destX == curX && 1 + destY == curY && (0x12c0120 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (curX == destX && destY - 1 == curY && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x12c0102) == 0) {
- return true;
- }
- } else if (rotation == 1) {
- if (curX == destX && destY + 1 == curY) {
- return true;
- }
- if (curX == destX - 1 && curY == destY && (0x12c0108 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (curX == 1 + destX && destY == curY && (0x12c0180 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- } else if (rotation == 2) {
- if (1 + destX == curX && destY == curY) {
- return true;
- }
- if (destX == curX && 1 + destY == curY && (0x12c0120 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (curX == destX && curY == destY - 1 && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x12c0102) == 0) {
- return true;
- }
- } else if (rotation == 3) {
- if (curX == destX && -1 + destY == curY) {
- return true;
- }
- if (curX == -1 + destX && destY == curY && (0x12c0108 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (curX == 1 + destX && destY == curY && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x12c0180) == 0) {
- return true;
- }
- }
- } else if (type == 2) {
- if (rotation == 0) {
- if (destX - 1 == curX && curY == destY) {
- return true;
- }
- if (destX == curX && curY == 1 + destY) {
- return true;
- }
- if (curX == destX + 1 && curY == destY && (0x12c0180 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (curX == destX && destY - 1 == curY && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x12c0102) == 0) {
- return true;
- }
- } else if (rotation == 1) {
- if (curX == destX - 1 && curY == destY && (0x12c0108 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (curX == destX && curY == 1 + destY) {
- return true;
- }
- if (1 + destX == curX && curY == destY) {
- return true;
- }
- if (curX == destX && destY - 1 == curY && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x12c0102) == 0) {
- return true;
- }
- } else if (rotation == 2) {
- if (destX - 1 == curX && destY == curY && (0x12c0108 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (destX == curX && 1 + destY == curY && (0x12c0120 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (1 + destX == curX && curY == destY) {
- return true;
- }
- if (curX == destX && curY == destY - 1) {
- return true;
- }
- } else if (rotation == 3) {
- if (destX - 1 == curX && curY == destY) {
- return true;
- }
- if (destX == curX && curY == destY + 1 && (0x12c0120 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (curX == 1 + destX && curY == destY && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x12c0180) == 0) {
- return true;
- }
- if (destX == curX && destY - 1 == curY) {
- return true;
- }
- }
- } else if (type == 9) {
- if (curX == destX && curY == destY + 1 && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x20) == 0) {
- return true;
- }
- if (curX == destX && curY == destY - 1 && (clipMaskSupplier.getClippingFlag(z, curX, curY) & 0x2) == 0) {
- return true;
- }
- if (curX == destX - 1 && curY == destY && (0x8 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- if (destX + 1 == curX && curY == destY && (0x80 & clipMaskSupplier.getClippingFlag(z, curX, curY)) == 0) {
- return true;
- }
- }
- } else {
- int cornerX = curX - (1 - size);
- int cornerY = -1 + curY + size;
- if (type == 0) {
- if (rotation == 0) {
- if (destX - size == curX && destY >= curY && destY <= cornerY) {
- return true;
- }
- if (destX >= curX && cornerX >= destX && curY == 1 + destY && (clipMaskSupplier.getClippingFlag(z, destX, curY) & 0x12c0120) == 0) {
- return true;
- }
- if (destX >= curX && cornerX >= destX && destY - size == curY && (clipMaskSupplier.getClippingFlag(z, destX, cornerY) & 0x12c0102) == 0) {
- return true;
- }
- } else if (rotation == 1) {
- if (destX >= curX && cornerX >= destX && destY + 1 == curY) {
- return true;
- }
- if (curX == -size + destX && destY >= curY && cornerY >= destY && (0x12c0108 & clipMaskSupplier.getClippingFlag(z, cornerX, destY)) == 0) {
- return true;
- }
- if (curX == 1 + destX && destY >= curY && cornerY >= destY && (clipMaskSupplier.getClippingFlag(z, curX, destY) & 0x12c0180) == 0) {
- return true;
- }
- } else if (rotation == 2) {
- if (curX == 1 + destX && curY <= destY && destY <= cornerY) {
- return true;
- }
- if (curX <= destX && cornerX >= destX && destY + 1 == curY && (0x12c0120 & clipMaskSupplier.getClippingFlag(z, destX, curY)) == 0) {
- return true;
- }
- if (destX >= curX && destX <= cornerX && destY - size == curY && (0x12c0102 & clipMaskSupplier.getClippingFlag(z, destX, cornerY)) == 0) {
- return true;
- }
- } else if (rotation == 3) {
- if (curX <= destX && destX <= cornerX && curY == -size + destY) {
- return true;
- }
- if (-size + destX == curX && curY <= destY && destY <= cornerY && (clipMaskSupplier.getClippingFlag(z, cornerX, destY) & 0x12c0108) == 0) {
- return true;
- }
- if (1 + destX == curX && curY <= destY && cornerY >= destY && (clipMaskSupplier.getClippingFlag(z, curX, destY) & 0x12c0180) == 0) {
- return true;
- }
- }
- }
- if (type == 2) {
- if (rotation == 0) {
- if (destX - size == curX && curY <= destY && destY <= cornerY) {
- return true;
- }
- if (curX <= destX && destX <= cornerX && curY == 1 + destY) {
- return true;
- }
- if (curX == 1 + destX && curY <= destY && destY <= cornerY && (0x12c0180 & clipMaskSupplier.getClippingFlag(z, curX, destY)) == 0) {
- return true;
- }
- if (curX <= destX && cornerX >= destX && -size + destY == curY && (clipMaskSupplier.getClippingFlag(z, destX, cornerY) & 0x12c0102) == 0) {
- return true;
- }
- } else if (rotation == 1) {
- if (-size + destX == curX && destY >= curY && destY <= cornerY && (clipMaskSupplier.getClippingFlag(z, cornerX, destY) & 0x12c0108) == 0) {
- return true;
- }
- if (destX >= curX && cornerX >= destX && curY == 1 + destY) {
- return true;
- }
- if (destX + 1 == curX && curY <= destY && destY <= cornerY) {
- return true;
- }
- if (destX >= curX && cornerX >= destX && destY + -size == curY && (0x12c0102 & clipMaskSupplier.getClippingFlag(z, destX, cornerY)) == 0) {
- return true;
- }
- } else if (rotation == 2) {
- if (curX == destX - size && curY <= destY && cornerY >= destY && (clipMaskSupplier.getClippingFlag(z, cornerX, destY) & 0x12c0108) == 0) {
- return true;
- }
- if (destX >= curX && destX <= cornerX && 1 + destY == curY && (0x12c0120 & clipMaskSupplier.getClippingFlag(z, destX, curY)) == 0) {
- return true;
- }
- if (1 + destX == curX && destY >= curY && cornerY >= destY) {
- return true;
- }
- if (curX <= destX && destX <= cornerX && curY == -size + destY) {
- return true;
- }
- } else if (rotation == 3) {
- if (destX + -size == curX && destY >= curY && destY <= cornerY) {
- return true;
- }
- if (curX <= destX && cornerX >= destX && curY == 1 + destY && (clipMaskSupplier.getClippingFlag(z, destX, curY) & 0x12c0120) == 0) {
- return true;
- }
- if (1 + destX == curX && destY >= curY && cornerY >= destY && (0x12c0180 & clipMaskSupplier.getClippingFlag(z, curX, destY)) == 0) {
- return true;
- }
- if (destX >= curX && destX <= cornerX && curY == -size + destY) {
- return true;
- }
- }
- }
- if (type == 9) {
- if (destX >= curX && destX <= cornerX && curY == 1 + destY && (clipMaskSupplier.getClippingFlag(z, destX, curY) & 0x12c0120) == 0) {
- return true;
- }
- if (destX >= curX && cornerX >= destX && curY == -size + destY && (0x12c0102 & clipMaskSupplier.getClippingFlag(z, destX, cornerY)) == 0) {
- return true;
- }
- if (-size + destX == curX && destY >= curY && cornerY >= destY && (0x12c0108 & clipMaskSupplier.getClippingFlag(z, cornerX, destY)) == 0) {
- return true;
- }
- if (curX == destX + 1 && destY >= curY && cornerY >= destY && (clipMaskSupplier.getClippingFlag(z, curX, destY) & 0x12c0180) == 0) {
- return true;
- }
- }
- }
- return false;
+ public static boolean canDoorInteract(int curX,
+ int curY,
+ int size,
+ int destX,
+ int destY,
+ int type,
+ int rotation,
+ int z,
+ ClipMaskSupplier clipMaskSupplier) {
+ return RsmodPathfinder.canReach(curX, curY, size, destX, destY, 1, 1, rotation, type, 0, z, clipMaskSupplier);
}
-
+
/**
* Checks if the mover is standing on the destination.
- * @param x The current x-location (in viewport).
- * @param y The current y-location (in viewport).
+ *
+ * @param x The current x-location (in viewport).
+ * @param y The current y-location (in viewport).
* @param moverSizeX The mover x size.
* @param moverSizeY The mover y size.
- * @param destX The destination x-location in viewport.
- * @param destY The destination y-location in viewport.
- * @param sizeX The destination node x-size.
- * @param sizeY The destination node y-size.
+ * @param destX The destination x-location in viewport.
+ * @param destY The destination y-location in viewport.
+ * @param sizeX The destination node x-size.
+ * @param sizeY The destination node y-size.
* @return {@code True} if so.
*/
public static boolean isStandingIn(int x, int y, int moverSizeX, int moverSizeY, int destX, int destY, int sizeX, int sizeY) {
@@ -599,120 +247,52 @@ public abstract class Pathfinder {
}
return true;
}
-
+
/**
* Checks if interaction is possible from the current location.
- * @param x The current x-location (in viewport).
- * @param y The current y-location (in viewport).
+ *
+ * @param x The current x-location (in viewport).
+ * @param y The current y-location (in viewport).
* @param moverSize The mover size.
- * @param destX The destination x-location in viewport.
- * @param destY The destination y-location in viewport.
- * @param sizeX The destination node x-size.
- * @param sizeY The destination node y-size.
- * @param walkFlag The walking flag.
+ * @param destX The destination x-location in viewport.
+ * @param destY The destination y-location in viewport.
+ * @param sizeX The destination node x-size.
+ * @param sizeY The destination node y-size.
+ * @param walkFlag The walking flag.
* @return {@code True} if so.
*/
- public static boolean canInteract(int x, int y, int moverSize, int destX, int destY, int sizeX, int sizeY, int walkFlag, int z, ClipMaskSupplier clipMaskSupplier) {
- if (moverSize > 1) {
- return isStandingIn(x, y, moverSize, moverSize, destX, destY, sizeX, sizeY) || canInteractSized(x, y, moverSize, moverSize, destX, destY, sizeX, sizeY, walkFlag, z);
- }
- int flag = clipMaskSupplier.getClippingFlag(z, x, y);
- int cornerX = destX + sizeX - 1;
- int cornerY = destY + sizeY - 1;
- if (destX <= x && cornerX >= x && y >= destY && y <= cornerY) {
- return true;
- }
- if (x == destX - 1 && destY <= y && y <= cornerY && (0x8 & flag) == 0 && (0x8 & walkFlag) == 0) {
- return true;
- }
- if (x == cornerX + 1 && destY <= y && cornerY >= y && (flag & 0x80) == 0 && (0x2 & walkFlag) == 0) {
- return true;
- }
- if (y == destY - 1 && destX <= x && cornerX >= x && (0x2 & flag) == 0 && (0x4 & walkFlag) == 0) {
- return true;
- }
- if (y == cornerY + 1 && destX <= x && cornerX >= x && (flag & 0x20) == 0 && (0x1 & walkFlag) == 0) {
- return true;
- }
- return false;
+ public static boolean canInteract(int x,
+ int y,
+ int moverSize,
+ int destX,
+ int destY,
+ int sizeX,
+ int sizeY,
+ int walkFlag,
+ int z,
+ ClipMaskSupplier clipMaskSupplier) {
+ return RsmodPathfinder.canReach(x, y, moverSize, destX, destY, sizeX, sizeY, 0, -1, walkFlag, z, clipMaskSupplier);
}
-
+
/**
* Checks if interaction is possible from the current location.
+ *
* @param destX The destination x-location in viewport.
* @param destY The destination y-location in viewport.
* @param sizeX The destination node x-size.
* @param sizeY The destination node y-size.
* @return {@code True} if so.
*/
- public static boolean canInteractSized(int curX, int curY, int moverSizeX, int moverSizeY, int destX, int destY, int sizeX, int sizeY, int walkingFlag, int z) {
- int fromCornerY = curY + moverSizeY;
- int fromCornerX = curX + moverSizeX;
- int toCornerX = sizeX + destX;
- int toCornerY = sizeY + destY;
- if (destX <= curX && curX < toCornerX) {
- if (destY == fromCornerY && (walkingFlag & 0x4) == 0) {
- int x = curX;
- for (int endX = toCornerX < fromCornerX ? toCornerX : fromCornerX; endX > x; x++) {
- if ((RegionManager.getClippingFlag(z, x, -1 + fromCornerY) & 0x2) == 0) {
- return true;
- }
- }
- } else if (toCornerY == curY && (walkingFlag & 0x1) == 0) {
- int x = curX;
- for (int endX = fromCornerX <= toCornerX ? fromCornerX : toCornerX; x < endX; x++) {
- if ((RegionManager.getClippingFlag(z, x, curY) & 0x20) == 0) {
- return true;
- }
- }
- }
- } else if (destX < fromCornerX && toCornerX >= fromCornerX) {
- if (fromCornerY == destY && (0x4 & walkingFlag) == 0) {
- for (int x = destX; fromCornerX > x; x++) {
- if ((RegionManager.getClippingFlag(z, x, -1 + (fromCornerY)) & 0x2) == 0) {
- return true;
- }
- }
- } else if (toCornerY == curY && (0x1 & walkingFlag) == 0) {
- for (int x = destX; fromCornerX > x; x++) {
- if ((RegionManager.getClippingFlag(z, x, curY) & 0x20) == 0) {
- return true;
- }
- }
- }
- } else if (curY < destY || curY >= toCornerY) {
- if (fromCornerY > destY && toCornerY >= fromCornerY) {
- if (fromCornerX == destX && (walkingFlag & 0x8) == 0) {
- for (int y = destY; y < fromCornerY; y++) {
- if ((RegionManager.getClippingFlag(z, -1 + fromCornerX, y) & 0x8) == 0) {
- return true;
- }
- }
- } else if (curX == toCornerX && (0x2 & walkingFlag) == 0) {
- for (int y = destY; fromCornerY > y; y++) {
- if ((RegionManager.getClippingFlag(z, curX, y) & 0x80) == 0) {
- return true;
- }
- }
- }
- }
- } else if (destX != fromCornerX || (0x8 & walkingFlag) != 0) {
- if (curX == toCornerX && (walkingFlag & 0x2) == 0) {
- int y = curY;
- for (int endY = fromCornerY <= toCornerY ? fromCornerY : toCornerY; y < endY; y++) {
- if ((0x80 & RegionManager.getClippingFlag(z, curX, y)) == 0) {
- return true;
- }
- }
- }
- } else {
- int y = curY;
- for (int endY = fromCornerY > toCornerY ? toCornerY : fromCornerY; endY > y; y++) {
- if ((RegionManager.getClippingFlag(z, fromCornerX - 1, y) & 0x8) == 0) {
- return true;
- }
- }
- }
- return false;
+ public static boolean canInteractSized(int curX,
+ int curY,
+ int moverSizeX,
+ int moverSizeY,
+ int destX,
+ int destY,
+ int sizeX,
+ int sizeY,
+ int walkingFlag,
+ int z) {
+ return RsmodPathfinder.canReach(curX, curY, moverSizeX, destX, destY, sizeX, sizeY, 0, -1, walkingFlag, z, null);
}
}
diff --git a/Server/src/main/core/game/world/map/path/ProjectilePathfinder.java b/Server/src/main/core/game/world/map/path/ProjectilePathfinder.java
deleted file mode 100644
index d20b96c22..000000000
--- a/Server/src/main/core/game/world/map/path/ProjectilePathfinder.java
+++ /dev/null
@@ -1,200 +0,0 @@
-package core.game.world.map.path;
-
-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 java.util.ArrayList;
-import java.util.List;
-
-/**
- * A pathfinder implementation used for checking projectile paths.
- * @author Emperor
- */
-public final class ProjectilePathfinder extends Pathfinder {
-
- /**
- * If a path can be found.
- */
- private boolean found;
-
- /**
- * The plane.
- */
- private int z;
-
- /**
- * The x-coordinate.
- */
- private int x;
-
- /**
- * The y-coordinate.
- */
- private int y;
-
- @Override
- public Path find(Location start, int size, Location end, int sizeX, int sizeY, int rotation, int type, int walkingFlag, boolean near, ClipMaskSupplier clipMaskSupplier) {
- Path path = new Path();
- z = start.getZ();
- x = start.getX();
- y = start.getY();
- List points = new ArrayList<>(20);
- path.setSuccesful(true);
- while (x != end.getX() || y != end.getY()) {
- Direction[] directions = getDirection(x, y, end);
- found = true;
- checkSingleTraversal(points, directions);
- if (!found) {
- path.setMoveNear(x != start.getX() || y != start.getY());
- path.setSuccesful(false);
- break;
- }
- }
- if (!points.isEmpty()) {
- for (int i = 0; i < points.size() - 1; i++) {
- Point p = points.get(i);
- if (p.getDirection() != null) {
- path.getPoints().add(p);
- }
- }
- path.getPoints().add(points.get(points.size() - 1));
- }
- return path;
- }
-
- /**
- * Checks traversal for a size 1 entity.
- *
- * @param points The points list.
- * @param directions The directions.
- */
- private void checkSingleTraversal(List points, Direction... directions) {
- dir:
- for (Direction dir : directions) {
- found = true;
- switch (dir) {
- case NORTH:
- if (flagged(z, x, y + 1, 0x12c0120)) {
- found = false;
- break dir;
- }
- points.add(new Point(x, y + 1, dir));
- y++;
- break;
- case NORTH_EAST:
- if (flagged(z, x + 1, y, 0x12c0180)
- || flagged(z, x, y + 1, 0x12c0120)
- || flagged(z, x + 1, y + 1, 0x12c01e0)) {
- found = false;
- break dir;
- }
- points.add(new Point(x + 1, y + 1, dir));
- x++;
- y++;
- break;
- case EAST:
- if (flagged(z, x + 1, y, 0x12c0180)) {
- found = false;
- break dir;
- }
- points.add(new Point(x + 1, y, dir));
- x++;
- break;
- case SOUTH_EAST:
- if (flagged(z, x + 1, y, 0x12c0180)
- || flagged(z, x, y - 1, 0x12c0102)
- || flagged(z, x + 1, y - 1, 0x12c0183)) {
- found = false;
- break dir;
- }
- points.add(new Point(x + 1, y - 1, dir));
- x++;
- y--;
- break;
- case SOUTH:
- if (flagged(z, x, y - 1, 0x12c0102)) {
- found = false;
- break dir;
- }
- points.add(new Point(x, y - 1, dir));
- y--;
- break;
- case SOUTH_WEST:
- if (flagged(z, x - 1, y, 0x12c0108)
- || flagged(z, x, y - 1, 0x12c0102)
- || flagged(z, x - 1, y - 1, 0x12c010e)) {
- found = false;
- break dir;
- }
- points.add(new Point(x - 1, y - 1, dir));
- x--;
- y--;
- break;
- case WEST:
- if (flagged(z, x - 1, y, 0x12c0108)) {
- found = false;
- break dir;
- }
- points.add(new Point(x - 1, y, dir));
- x--;
- break;
- case NORTH_WEST:
- if (flagged(z, x - 1, y, 0x12c0108)
- || flagged(z, x, y + 1, 0x12c0120)
- || flagged(z, x - 1, y + 1, 0x12c0138)) {
- found = false;
- break dir;
- }
- points.add(new Point(x - 1, y + 1, dir));
- x--;
- y++;
- break;
- }
- if (found) {
- break;
- }
- }
- }
-
- private static boolean flagged(int z, int x, int y, int pFlagMask) {
- int pFlag = RegionManager.getProjectileFlag(z, x, y);
- return (pFlag & pFlagMask) != 0 || (pFlag & 0x20000) != 0 || (RegionManager.getClippingFlag(z, x, y) & 0x20000) != 0;
- }
-
- /**
- * Gets the direction.
- * @param startX The startX.
- * @param startY The startY.
- * @param end The end direction.
- * @return The direction.
- */
- private static Direction[] getDirection(int startX, int startY, Location end) {
- int endX = end.getX();
- int endY = end.getY();
- if (startX == endX) {
- if (startY > endY) {
- return new Direction[] { Direction.SOUTH };
- } else if (startY < endY) {
- return new Direction[] { Direction.NORTH };
- }
- } else if (startY == endY) {
- if (startX > endX) {
- return new Direction[] { Direction.WEST };
- }
- return new Direction[] { Direction.EAST };
- } else {
- if (startX < endX && startY < endY) {
- return new Direction[] { Direction.NORTH_EAST, Direction.EAST, Direction.NORTH };
- } else if (startX < endX && startY > endY) {
- return new Direction[] { Direction.SOUTH_EAST, Direction.EAST, Direction.SOUTH };
- } else if (startX > endX && startY < endY) {
- return new Direction[] { Direction.NORTH_WEST, Direction.WEST, Direction.NORTH };
- } else if (startX > endX && startY > endY) {
- return new Direction[] { Direction.SOUTH_WEST, Direction.WEST, Direction.SOUTH };
- }
- }
- return new Direction[0];
- }
-}
\ No newline at end of file
diff --git a/Server/src/main/core/game/world/map/path/RsmodPathfinder.kt b/Server/src/main/core/game/world/map/path/RsmodPathfinder.kt
new file mode 100644
index 000000000..6241a9955
--- /dev/null
+++ b/Server/src/main/core/game/world/map/path/RsmodPathfinder.kt
@@ -0,0 +1,361 @@
+package core.game.world.map.path
+
+import core.ServerConstants
+import core.api.utils.Vector
+import core.game.world.map.Location
+import core.game.world.map.Point
+import core.game.world.map.RegionManager
+import org.rsmod.game.pathfinder.LinePathFinder
+import org.rsmod.game.pathfinder.LineValidator
+import org.rsmod.game.pathfinder.PathFinder
+import org.rsmod.game.pathfinder.RayCast
+import org.rsmod.game.pathfinder.collision.CollisionFlagMap
+import org.rsmod.game.pathfinder.reach.ReachStrategy
+import kotlin.math.floor
+
+private const val SEARCH_MAP_SIZE = 128
+private const val RING_BUFFER_SIZE = 4096
+
+class RsmodPathfinder(private val maxWaypoints: Int = 25) : Pathfinder() {
+
+ private val defaultFinder = ThreadLocal.withInitial {
+ PathFinder(RegionManager.RSMOD_CLIPPING_FLAGS, SEARCH_MAP_SIZE, RING_BUFFER_SIZE)
+ }
+ private val suppliedFinder = ThreadLocal.withInitial { RouteFinderState() }
+
+ override fun find(
+ start: Location?,
+ moverSize: Int,
+ dest: Location?,
+ sizeX: Int,
+ sizeY: Int,
+ rotation: Int,
+ type: Int,
+ walkingFlag: Int,
+ near: Boolean,
+ clipMaskSupplier: ClipMaskSupplier?,
+ ): Path {
+ val source = requireNotNull(start)
+ val destination = requireNotNull(dest)
+ val path = Path()
+ var end = destination
+ val vector = Vector.betweenLocs(source, destination)
+ val magnitude = floor(vector.magnitude())
+
+ if (magnitude > ServerConstants.MAX_PATHFIND_DISTANCE) {
+ if (canAttempt(source, destination)) {
+ end =
+ source.transform(
+ vector.normalized() * (ServerConstants.MAX_PATHFIND_DISTANCE - 1)
+ )
+ } else {
+ path.isMoveNear = true
+ return path
+ }
+ }
+
+ val shape = routeShape(type, sizeX, sizeY)
+ val finder =
+ if (clipMaskSupplier == null) {
+ RegionManager.loadClippingWindow(source, SEARCH_MAP_SIZE)
+ defaultFinder.get()
+ } else {
+ val state = suppliedFinder.get()
+ state.loadCollisionWindow(source, clipMaskSupplier)
+ state.finder
+ }
+ val route =
+ finder.findPath(
+ level = source.z,
+ srcX = source.x,
+ srcZ = source.y,
+ destX = end.x,
+ destZ = end.y,
+ srcSize = moverSize,
+ destWidth = if (sizeX == 0) 1 else sizeX,
+ destHeight = if (sizeY == 0) 1 else sizeY,
+ objRot = rotation,
+ objShape = shape,
+ moveNear = near,
+ blockAccessFlags = walkingFlag,
+ maxWaypoints = maxWaypoints,
+ )
+
+ if (route.failed) {
+ return path
+ }
+
+ var currentX = source.x
+ var currentY = source.y
+ path.points.add(Point(currentX, currentY))
+ for (waypoint in route.waypoints) {
+ while (currentX != waypoint.x || currentY != waypoint.z) {
+ currentX += waypoint.x.compareTo(currentX)
+ currentY += waypoint.z.compareTo(currentY)
+ path.points.add(Point(currentX, currentY))
+ }
+ }
+ path.setSuccesful(true)
+ path.isMoveNear = route.alternative || end != destination
+ return path
+ }
+
+ companion object {
+ @JvmStatic
+ fun canAttempt(start: Location, dest: Location): Boolean {
+ val distance = floor(Vector.betweenLocs(start, dest).magnitude())
+ return distance < ServerConstants.MAX_PATHFIND_DISTANCE * 2.0
+ }
+
+ @JvmStatic
+ fun canReach(
+ srcX: Int,
+ srcY: Int,
+ moverSize: Int,
+ destX: Int,
+ destY: Int,
+ destWidth: Int,
+ destHeight: Int,
+ rotation: Int,
+ type: Int,
+ walkingFlag: Int,
+ z: Int,
+ clipMaskSupplier: ClipMaskSupplier?,
+ ): Boolean {
+ if (clipMaskSupplier == null) {
+ RegionManager.loadClippingWindow(Location.create(srcX, srcY, z), SEARCH_MAP_SIZE)
+ return ReachStrategy.reached(
+ flags = RegionManager.RSMOD_CLIPPING_FLAGS,
+ level = z,
+ srcX = srcX,
+ srcZ = srcY,
+ destX = destX,
+ destZ = destY,
+ destWidth = if (destWidth == 0) 1 else destWidth,
+ destHeight = if (destHeight == 0) 1 else destHeight,
+ srcSize = moverSize,
+ objRot = rotation,
+ objShape = routeShape(type, destWidth, destHeight),
+ blockAccessFlags = walkingFlag,
+ )
+ }
+ // Reach checks only read tiles within the source/destination rectangles and
+ // their cross-axis combinations, so loading just their padded bounding box
+ // into a reused map produces the same reads as a freshly allocated full map.
+ val flags = suppliedReachFlags.get()
+ val destSize =
+ maxOf(if (destWidth == 0) 1 else destWidth, if (destHeight == 0) 1 else destHeight)
+ val minX = maxOf(0, minOf(srcX, destX) - 1)
+ val minY = maxOf(0, minOf(srcY, destY) - 1)
+ val maxX = maxOf(srcX + moverSize, destX + destSize) + 1
+ val maxY = maxOf(srcY + moverSize, destY + destSize) + 1
+ try {
+ for (x in minX..maxX) {
+ for (y in minY..maxY) {
+ flags[x, y, z] = clipMaskSupplier.getClippingFlag(z, x, y)
+ }
+ }
+ return ReachStrategy.reached(
+ flags = flags,
+ level = z,
+ srcX = srcX,
+ srcZ = srcY,
+ destX = destX,
+ destZ = destY,
+ destWidth = if (destWidth == 0) 1 else destWidth,
+ destHeight = if (destHeight == 0) 1 else destHeight,
+ srcSize = moverSize,
+ objRot = rotation,
+ objShape = routeShape(type, destWidth, destHeight),
+ blockAccessFlags = walkingFlag,
+ )
+ } finally {
+ for (zoneX in (minX shr 3)..(maxX shr 3)) {
+ for (zoneY in (minY shr 3)..(maxY shr 3)) {
+ flags.deallocateIfPresent(zoneX shl 3, zoneY shl 3, z)
+ }
+ }
+ }
+ }
+
+ @JvmStatic
+ fun lineOfSight(
+ start: Location,
+ dest: Location,
+ moverSize: Int,
+ destWidth: Int,
+ destHeight: Int,
+ ): RayCast {
+ RegionManager.loadClippingWindow(start, SEARCH_MAP_SIZE)
+ return lineOfSightLoaded(start, dest, moverSize, destWidth, destHeight)
+ }
+
+ private fun lineOfSightLoaded(
+ start: Location,
+ dest: Location,
+ moverSize: Int,
+ destWidth: Int,
+ destHeight: Int,
+ ): RayCast {
+ return projectileLineFinder
+ .get()
+ .lineOfSight(
+ level = start.z,
+ srcX = start.x,
+ srcZ = start.y,
+ destX = dest.x,
+ destZ = dest.y,
+ srcSize = moverSize,
+ destWidth = destWidth.coerceAtLeast(1),
+ destHeight = destHeight.coerceAtLeast(1),
+ )
+ }
+
+ @JvmStatic
+ fun hasLineOfSight(
+ start: Location,
+ dest: Location,
+ moverSize: Int,
+ destWidth: Int,
+ destHeight: Int,
+ maxRaySteps: Int = Int.MAX_VALUE,
+ ): Boolean {
+ val rayCast = lineOfSight(start, dest, moverSize, destWidth, destHeight)
+ return rayCast.success && rayCast.coordinates.size <= maxRaySteps
+ }
+
+ @JvmStatic
+ fun hasLineOfSightBetween(
+ sourceLocation: Location,
+ sourceSize: Int,
+ targetLocation: Location,
+ targetSize: Int,
+ maxRaySteps: Int = Int.MAX_VALUE,
+ ): Boolean {
+ RegionManager.loadClippingWindow(sourceLocation, SEARCH_MAP_SIZE)
+ return hasLineOfSightBetweenLoaded(
+ sourceLocation,
+ sourceSize,
+ targetLocation,
+ targetSize,
+ maxRaySteps,
+ )
+ }
+
+ @JvmStatic
+ fun loadLineOfSightWindow(center: Location) {
+ RegionManager.loadClippingWindow(center, SEARCH_MAP_SIZE)
+ }
+
+ @JvmStatic
+ fun hasLineOfSightBetweenLoaded(
+ sourceLocation: Location,
+ sourceSize: Int,
+ targetLocation: Location,
+ targetSize: Int,
+ maxRaySteps: Int = Int.MAX_VALUE,
+ ): Boolean {
+ if (sourceSize == 1 && targetSize == 1) {
+ if (maxRaySteps == Int.MAX_VALUE) {
+ return hasSingleTileLineOfSight(sourceLocation, targetLocation)
+ }
+ if (maxRaySteps == 1) {
+ if (manhattanDistance(sourceLocation, targetLocation) > 1) {
+ return false
+ }
+ return hasSingleTileLineOfSight(sourceLocation, targetLocation)
+ }
+ }
+ for (sourceX in 0 until sourceSize) {
+ for (sourceY in 0 until sourceSize) {
+ val source = sourceLocation.transform(sourceX, sourceY, 0)
+ for (targetX in 0 until targetSize) {
+ for (targetY in 0 until targetSize) {
+ if (maxRaySteps == 1) {
+ // A ray between distinct tiles emits a coordinate per axis
+ // step, so tiles further than one orthogonal step apart can
+ // never satisfy a single-step ray.
+ val manhattan =
+ kotlin.math.abs(source.x - (targetLocation.x + targetX)) +
+ kotlin.math.abs(source.y - (targetLocation.y + targetY))
+ if (manhattan > 1) {
+ continue
+ }
+ }
+ val destination = targetLocation.transform(targetX, targetY, 0)
+ val rayCast = lineOfSightLoaded(source, destination, 1, 1, 1)
+ if (rayCast.success && rayCast.coordinates.size <= maxRaySteps) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ return false
+ }
+
+ private fun hasSingleTileLineOfSight(
+ sourceLocation: Location,
+ targetLocation: Location,
+ ): Boolean {
+ return projectileLineValidator
+ .get()
+ .hasLineOfSight(
+ level = sourceLocation.z,
+ srcX = sourceLocation.x,
+ srcZ = sourceLocation.y,
+ destX = targetLocation.x,
+ destZ = targetLocation.y,
+ srcSize = 1,
+ destWidth = 1,
+ destHeight = 1,
+ )
+ }
+
+ private fun manhattanDistance(first: Location, second: Location): Int {
+ val dx = kotlin.math.abs(first.x - second.x)
+ val dy = kotlin.math.abs(first.y - second.y)
+ return dx + dy
+ }
+
+ private fun routeShape(type: Int, sizeX: Int, sizeY: Int): Int =
+ when {
+ type >= 0 -> type
+ sizeX != 0 && sizeY != 0 -> 10
+ else -> -1
+ }
+
+ private val suppliedReachFlags = ThreadLocal.withInitial { CollisionFlagMap() }
+
+ private val projectileLineValidator = ThreadLocal.withInitial {
+ LineValidator(RegionManager.RSMOD_PROJECTILE_FLAGS)
+ }
+
+ private val projectileLineFinder = ThreadLocal.withInitial {
+ LinePathFinder(RegionManager.RSMOD_PROJECTILE_FLAGS)
+ }
+
+ private fun loadCollisionWindow(
+ flags: CollisionFlagMap,
+ start: Location,
+ supplier: ClipMaskSupplier,
+ ) {
+ val baseX = start.x - (SEARCH_MAP_SIZE / 2)
+ val baseY = start.y - (SEARCH_MAP_SIZE / 2)
+ for (x in baseX until baseX + SEARCH_MAP_SIZE) {
+ for (y in baseY until baseY + SEARCH_MAP_SIZE) {
+ flags[x, y, start.z] = supplier.getClippingFlag(start.z, x, y)
+ }
+ }
+ }
+ }
+
+ private class RouteFinderState {
+ val flags = CollisionFlagMap()
+ val finder = PathFinder(flags, SEARCH_MAP_SIZE, RING_BUFFER_SIZE)
+
+ fun loadCollisionWindow(start: Location, supplier: ClipMaskSupplier) {
+ loadCollisionWindow(flags, start, supplier)
+ }
+ }
+}
diff --git a/Server/src/main/core/game/world/map/path/RsmodProjectilePathfinder.kt b/Server/src/main/core/game/world/map/path/RsmodProjectilePathfinder.kt
new file mode 100644
index 000000000..7cfe81d11
--- /dev/null
+++ b/Server/src/main/core/game/world/map/path/RsmodProjectilePathfinder.kt
@@ -0,0 +1,40 @@
+package core.game.world.map.path
+
+import core.game.world.map.Location
+import core.game.world.map.Point
+
+class RsmodProjectilePathfinder : Pathfinder() {
+ override fun find(
+ start: Location?,
+ size: Int,
+ end: Location?,
+ sizeX: Int,
+ sizeY: Int,
+ rotation: Int,
+ type: Int,
+ walkingFlag: Int,
+ near: Boolean,
+ clipMaskSupplier: ClipMaskSupplier?,
+ ): Path {
+ val source = requireNotNull(start)
+ val destination = requireNotNull(end)
+ val rayCast =
+ RsmodPathfinder.lineOfSight(
+ start = source,
+ dest = destination,
+ moverSize = size,
+ destWidth = sizeX,
+ destHeight = sizeY,
+ )
+ val path = Path()
+ for (coordinate in rayCast.coordinates) {
+ path.points.add(Point(coordinate.x, coordinate.z))
+ }
+ if (!rayCast.success) {
+ path.isMoveNear = rayCast.alternative
+ return path
+ }
+ path.setSuccesful(true)
+ return path
+ }
+}
diff --git a/Server/src/main/core/game/world/map/path/SmartPathfinder.kt b/Server/src/main/core/game/world/map/path/SmartPathfinder.kt
deleted file mode 100644
index 2dcae4a9c..000000000
--- a/Server/src/main/core/game/world/map/path/SmartPathfinder.kt
+++ /dev/null
@@ -1,591 +0,0 @@
-package core.game.world.map.path
-
-import core.game.world.GameWorld
-import core.game.world.map.Direction
-import core.game.world.map.Location
-import core.game.world.map.Point
-import core.tools.*
-import core.api.*
-import core.api.utils.Vector
-import core.ServerConstants
-
-import java.util.Comparator
-import java.util.PriorityQueue
-
-import java.io.*
-import javax.imageio.ImageIO
-import java.awt.image.BufferedImage
-
-class SmartPathfinder
-/**
- * Constructs a new `SmartPathfinder` `Object`.
- */
-internal constructor() : Pathfinder() {
- /**
- * The x-queue.
- */
- private var queueX: IntArray = intArrayOf(0)
-
- /**
- * The y-queue.
- */
- private var queueY: IntArray = intArrayOf(0)
-
- /**
- * The "via" array.
- */
- private var via: Array = Array(104) { IntArray(104) }
-
- /**
- * The cost array.
- */
- private var cost: Array = Array(104) { IntArray(104) }
-
- /**
- * The current writing position.
- */
- private var writePathPosition = 0
-
- /**
- * The current x-coordinate.
- */
- private var curX = 0
-
- /**
- * The current y-coordinate.
- */
- private var curY = 0
-
- /**
- * The destination x-coordinate.
- */
- private var dstX = 0
-
- /**
- * The destination y-coordinate.
- */
- private var dstY = 0
-
- /**
- * If a path was found.
- */
- private var foundPath = false
-
- /**
- * Resets the pathfinder.
- */
- fun reset() {
- queueX = IntArray(4096)
- queueY = IntArray(4096)
- via = Array(104) { IntArray(104) }
- cost = Array(104) { IntArray(104) }
- writePathPosition = 0
- }
-
- /**
- * Checks a tile.
- * @param x The x-coordinate.
- * @param y The y-coordinate.
- * @param dir The direction.
- * @param currentCost The current cost.
- */
- fun check(x: Int, y: Int, dir: Int, currentCost: Int, diagonalPenalty: Int = 0) {
- if(cost[x][y] > currentCost + diagonalPenalty) {
- queueX[writePathPosition] = x
- queueY[writePathPosition] = y
- via[x][y] = dir
- cost[x][y] = currentCost + diagonalPenalty
- writePathPosition = writePathPosition + 1 and 0xfff
- }
- }
-
- override fun find(start: Location?, moverSize: Int, dest: Location?, sizeX: Int, sizeY: Int, rotation: Int, type: Int, walkingFlag: Int, near: Boolean, clipMaskSupplier: ClipMaskSupplier?): Path {
- reset()
- assert(start != null && dest != null)
- var vec = Vector.betweenLocs(start!!, dest!!)
- var mag = kotlin.math.floor(vec.magnitude())
- var end = dest!!
- if (mag > ServerConstants.MAX_PATHFIND_DISTANCE) {
- try {
- if (mag < 50.0) { //truncate the path if it's realistically long
- vec = vec.normalized() * (ServerConstants.MAX_PATHFIND_DISTANCE - 1)
- end = start!!.transform(vec)
- } else throw Exception("Pathfinding distance exceeds server max! -> " + mag.toString() + " {" + start + "->" + end + "}")
- } catch (e: Exception) {
- val sw = StringWriter()
- val pw = PrintWriter(sw)
- e.printStackTrace(pw)
- log(this::class.java, Log.FINE, sw.toString())
- val p = Path()
- p.isMoveNear = true
- return p
- }
- }
- val path = Path()
- foundPath = false
- for (x in 0..103) {
- for (y in 0..103) {
- via[x][y] = 0
- cost[x][y] = 99999999
- }
- }
- val z = start!!.z
- val location = Location.create(start.regionX - 6 shl 3, start.regionY - 6 shl 3, z)
- curX = start.sceneX
- curY = start.sceneY
- dstX = end!!.getSceneX(start)
- dstY = end.getSceneY(start)
- var attempts: Int
- var readPosition: Int
- check(curX, curY, 99, 0)
- try {
- if (moverSize < 2) {
- if(GameWorld.settings?.smartpathfinder_bfs ?: false) {
- checkSingleTraversal(end, sizeX, sizeY, type, rotation, walkingFlag, location, clipMaskSupplier!!)
- } else {
- checkSingleTraversalAstar(end, sizeX, sizeY, type, rotation, walkingFlag, location, clipMaskSupplier!!)
- }
- } else if (moverSize == 2) {
- checkDoubleTraversal(end, sizeX, sizeY, type, rotation, walkingFlag, location, clipMaskSupplier!!)
- } else {
- checkVariableTraversal(end, moverSize, sizeX, sizeY, type, rotation, walkingFlag, location, clipMaskSupplier!!)
- }
- } catch (e: Exception) {}
- var debugImg = if(false) { BufferedImage(4*104+2, 104, BufferedImage.TYPE_INT_RGB) } else { null }
- if(debugImg != null) {
- for(y in 0 until 104) {
- for(x in 0 until 104) {
- debugImg.setRGB(x, 103-y, via[x][y] * (((1 shl 24)-1)/12))
- val c = Math.min(4*Math.min(cost[x][y], 64), 255)
- debugImg.setRGB(105+x, 103-y, (c shl 16) or (c shl 8) or c)
- debugImg.setRGB(2*105+x, 103-y, clipMaskSupplier!!.getClippingFlag(location.z, location.x + x, location.y + y))
- }
- }
- }
-
- if (!foundPath) {
- if (near) {
- var fullCost = 1000
- var thisCost = 100
- val depth = 10
- for (x in dstX - depth..dstX + depth) {
- for (y in dstY - depth..dstY + depth) {
- if (x >= 0 && y >= 0 && x < 104 && y < 104 && cost[x][y] < 100) {
- var diffX = 0
- if (x < dstX) {
- diffX = dstX - x
- } else if (x > dstX + sizeX - 1) {
- diffX = x - (dstX + sizeX - 1)
- }
- var diffY = 0
- if (y < dstY) {
- diffY = dstY - y
- } else if (y > dstY + sizeY - 1) {
- diffY = y - (dstY + sizeY - 1)
- }
- val totalCost = diffX * diffX + diffY * diffY
- if (totalCost < fullCost || totalCost == fullCost && cost[x][y] < thisCost) {
- fullCost = totalCost
- thisCost = cost[x][y]
- curX = x
- curY = y
- }
- }
- }
- }
- if (fullCost == 1000) {
- return path
- }
- path.isMoveNear = true
- }
- }
- readPosition = 0
- queueX[readPosition] = curX
- queueY[readPosition++] = curY
- var previousDirection: Int
- attempts = 0
- var directionFlag = via[curX][curY].also { previousDirection = it }
- while (curX != start.sceneX || curY != start.sceneY) {
- if (++attempts > queueX.size) {
- return path
- }
- previousDirection = directionFlag
- queueX[readPosition] = curX
- queueY[readPosition++] = curY
- if (directionFlag and WEST_FLAG != 0) {
- curX++
- } else if (directionFlag and EAST_FLAG != 0) {
- curX--
- }
- if (directionFlag and SOUTH_FLAG != 0) {
- curY++
- } else if (directionFlag and NORTH_FLAG != 0) {
- curY--
- }
- if(debugImg != null) {
- debugImg.setRGB(3*105+curX, 103-curY, 0x0000ff)
- }
- directionFlag = via[curX][curY]
- }
- if(debugImg != null) {
- debugImg.setRGB(3*105+start.sceneX, 103-start.sceneY, 0xff0000)
- debugImg.setRGB(3*105+dstX, 103-dstY, 0x00ff00)
- if(GameWorld.settings?.smartpathfinder_bfs ?: false) {
- ImageIO.write(debugImg, "png", File(String.format("bfs_%04d_%04d_%04d_%04d.png", start.x, start.y, end.x, end.y)))
- } else {
- ImageIO.write(debugImg, "png", File(String.format("astar_%04d_%04d_%04d_%04d.png", start.x, start.y, end.x, end.y)))
- }
- }
- val size = readPosition--
- var absX = location.x + queueX[readPosition]
- var absY = location.y + queueY[readPosition]
- path.points.add(Point(absX, absY))
- for (i in 1 until size) {
- readPosition--
- absX = location.x + queueX[readPosition]
- absY = location.y + queueY[readPosition]
- path.points.add(Point(absX, absY))
- }
- path.setSuccesful(true)
- if (end != dest)
- path.isMoveNear = true
- return path
- }
-
- class UIntAsPointComparator(val end: Location) : Comparator {
- override fun compare(p: UInt, q: UInt): Int {
- val pc: UInt = (p and 0x00ff0000u) shr 16
- val px: UInt = (p and 0x0000ff00u) shr 8
- val py: UInt = (p and 0x000000ffu)
- val qc: UInt = (q and 0x00ff0000u) shr 16
- val qx: UInt = (q and 0x0000ff00u) shr 8
- val qy: UInt = (q and 0x000000ffu)
- //val dp = pc.toInt() + Math.abs(end.sceneX - (px.toInt())) + Math.abs(end.sceneY - (py.toInt()))
- //val dq = qc.toInt() + Math.abs(end.sceneX - (qx.toInt())) + Math.abs(end.sceneY - (qy.toInt()))
- val dp = pc.toDouble() + Math.max(Math.abs(end.sceneX - px.toInt()), Math.abs(end.sceneY - py.toInt())).toDouble()
- val dq = qc.toDouble() + Math.max(Math.abs(end.sceneX - qx.toInt()), Math.abs(end.sceneY - qy.toInt())).toDouble()
- if(dp < dq) {
- return -1
- } else if(dq < dp) {
- return 1
- } else {
- return 0
- }
- }
- override fun equals(other: Any?): Boolean {
- if(other is UIntAsPointComparator) {
- return end == other.end
- } else {
- return false
- }
- }
- override fun hashCode(): Int {
- return end.hashCode()
- }
- }
-
- private fun checkSingleTraversalAstar(end: Location, sizeX: Int, sizeY: Int, type: Int, rotation: Int, walkingFlag: Int, location: Location, clipMaskSupplier: ClipMaskSupplier) {
- val z = location.z
- var queue = PriorityQueue(4096, UIntAsPointComparator(end))
- queue.add(((curX.toUInt()) shl 8) or (curY.toUInt()))
- while(!foundPath && !queue.isEmpty()) {
- val point = queue.poll()
- val curCost = ((point and 0xff0000u) shr 16).toInt()
- curX = ((point and 0x0000ff00u) shr 8).toInt()
- curY = (point and 0x000000ffu).toInt()
- val absX = location.x + curX
- val absY = location.y + curY
- if (curX == dstX && curY == dstY) {
- foundPath = true
- break
- }
- if (type != 0) {
- if ((type < 5 || type == 10) && canDoorInteract(absX, absY, 1, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- if (type < 10 && canDecorationInteract(absX, absY, 1, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- }
- if (sizeX != 0 && sizeY != 0 && canInteract(absX, absY, 1, end.x, end.y, sizeX, sizeY, walkingFlag, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- val newCost = curCost + 1
- //val orthogonalsFirst = arrayOf(Direction.EAST, Direction.NORTH, Direction.WEST, Direction.SOUTH, Direction.NORTH_EAST, Direction.NORTH_WEST, Direction.SOUTH_WEST, Direction.SOUTH_EAST)
- val orthogonalsFirst = arrayOf(Direction.SOUTH, Direction.WEST, Direction.NORTH, Direction.EAST, Direction.SOUTH_WEST, Direction.NORTH_WEST, Direction.SOUTH_EAST, Direction.NORTH_EAST)
- //val orthogonalsFirst = arrayOf(Direction.SOUTH, Direction.WEST, Direction.NORTH, Direction.EAST)
- //for(dir in Direction.values()) {
- for(dir in orthogonalsFirst) {
- val newSceneX: Int = curX + dir.stepX
- val newSceneY: Int = curY + dir.stepY
- if(0 <= newSceneX && newSceneX < 104 && 0 <= newSceneY && newSceneY < 104 && via[newSceneX][newSceneY] == 0) {
- if(dir.canMoveFrom(z, absX, absY, clipMaskSupplier)) {
- val diagonalPenalty = Math.abs(dir.stepX) + Math.abs(dir.stepY) - 1
- val flag = flagForDirection(dir)
- check(newSceneX, newSceneY, flag, newCost, diagonalPenalty)
- if(via[newSceneX][newSceneY] == flag) {
- queue.add(((newCost + diagonalPenalty).toUInt() shl 16) or (newSceneX.toUInt() shl 8) or newSceneY.toUInt())
- }
- }
- }
- }
- }
- }
-
- /**
- * Checks possible traversal for a size 1 entity.
- * @param end The destination location.
- * @param sizeX The x-size of the destination.
- * @param sizeY The y-size of the destination.
- * @param type The object type.
- * @param rotation The object rotation.
- * @param walkingFlag The walking flag.
- * @param location The viewport location.
- */
- private fun checkSingleTraversal(end: Location, sizeX: Int, sizeY: Int, type: Int, rotation: Int, walkingFlag: Int, location: Location, clipMaskSupplier: ClipMaskSupplier) {
- var readPosition = 0
- val z = location.z
- while (writePathPosition != readPosition) {
- curX = queueX[readPosition]
- curY = queueY[readPosition]
- readPosition = readPosition + 1 and 0xfff
- if (curX == dstX && curY == dstY) {
- foundPath = true
- break
- }
- try {
- val absX = location.x + curX
- val absY = location.y + curY
- if (type != 0) {
- if ((type < 5 || type == 10) && canDoorInteract(absX, absY, 1, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- if (type < 10 && canDecorationInteract(absX, absY, 1, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- }
- if (sizeX != 0 && sizeY != 0 && canInteract(absX, absY, 1, end.x, end.y, sizeX, sizeY, walkingFlag, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- val thisCost = cost[curX][curY] + 1
- if (curY > 0 && via[curX][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY - 1) and 0x12c0102 == 0) {
- check(curX, curY - 1, SOUTH_FLAG, thisCost)
- }
- if (curX > 0 && via[curX - 1][curY] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY) and 0x12c0108 == 0) {
- check(curX - 1, curY, WEST_FLAG, thisCost)
- }
- if (curY < 103 && via[curX][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY + 1) and 0x12c0120 == 0) {
- check(curX, curY + 1, NORTH_FLAG, thisCost)
- }
- if (curX < 103 && via[curX + 1][curY] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY) and 0x12c0180 == 0) {
- check(curX + 1, curY, EAST_FLAG, thisCost)
- }
- if (curX > 0 && curY > 0 && via[curX - 1][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY - 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY) and 0x12c0108 == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY - 1) and 0x12c0102 == 0) {
- check(curX - 1, curY - 1, SOUTH_WEST_FLAG, thisCost)
- }
- if (curX > 0 && curY < 103 && via[curX - 1][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + 1) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY) and 0x12c0108 == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY + 1) and 0x12c0120 == 0) {
- check(curX - 1, curY + 1, NORTH_WEST_FLAG, thisCost)
- }
- if (curX < 103 && curY > 0 && via[curX + 1][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY - 1) and 0x12c0183 == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY) and 0x12c0180 == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY - 1) and 0x12c0102 == 0) {
- check(curX + 1, curY - 1, SOUTH_EAST_FLAG, thisCost)
- }
- if (curX < 103 && curY < 103 && via[curX + 1][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY + 1) and 0x12c01e0 == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY) and 0x12c0180 == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY + 1) and 0x12c0120 == 0) {
- check(curX + 1, curY + 1, NORTH_EAST_FLAG, thisCost)
- }
- } catch (e: Exception) {
- // e.printStackTrace()println("curX " + curX + " curY" + curY + " via " + via[curX + 1] + via[curY + 1])
- }
- }
- }
-
- /**
- * Checks possible traversal for a size 2 entity.
- * @param end The destination location.
- * @param sizeX The x-size of the destination.
- * @param sizeY The y-size of the destination.
- * @param type The object type.
- * @param rotation The object rotation.
- * @param walkingFlag The walking flag.
- * @param location The viewport location.
- */
- private fun checkDoubleTraversal(end: Location, sizeX: Int, sizeY: Int, type: Int, rotation: Int, walkingFlag: Int, location: Location, clipMaskSupplier: ClipMaskSupplier) {
- var readPosition = 0
- val z = location.z
- while (writePathPosition != readPosition) {
- curX = queueX[readPosition]
- curY = queueY[readPosition]
- readPosition = readPosition + 1 and 0xfff
- if (curX == dstX && curY == dstY) {
- foundPath = true
- break
- }
- val absX = location.x + curX
- val absY = location.y + curY
- if (type != 0) {
- if ((type < 5 || type == 10) && canDoorInteract(absX, absY, 2, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- if (type < 10 && canDecorationInteract(absX, absY, 2, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- }
- if (sizeX != 0 && sizeY != 0 && canInteract(absX, absY, 2, end.x, end.y, sizeX, sizeY, walkingFlag, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- val thisCost = cost[curX][curY] + 1
- if (curY > 0 && via[curX][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY - 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY - 1) and 0x12c0183 == 0) {
- check(curX, curY - 1, SOUTH_FLAG, thisCost)
- }
- if (curX > 0 && via[curX - 1][curY] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + 1) and 0x12c0138 == 0) {
- check(curX - 1, curY, WEST_FLAG, thisCost)
- }
- if (curY < 102 && via[curX][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY + 2) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY + 2) and 0x12c01e0 == 0) {
- check(curX, curY + 1, NORTH_FLAG, thisCost)
- }
- if (curX < 102 && via[curX + 1][curY] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 2, absY) and 0x12c0183 == 0 && clipMaskSupplier.getClippingFlag(z, absX + 2, absY + 1) and 0x12c01e0 == 0) {
- check(curX + 1, curY, EAST_FLAG, thisCost)
- }
- if (curX > 0 && curY > 0 && via[curX - 1][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY - 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY - 1) and 0x12c0183 == 0) {
- check(curX - 1, curY - 1, SOUTH_WEST_FLAG, thisCost)
- }
- if (curX > 0 && curY < 102 && via[curX - 1][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + 2) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY + 2) and 0x12c01e0 == 0) {
- check(curX - 1, curY + 1, NORTH_WEST_FLAG, thisCost)
- }
- if (curX < 102 && curY > 0 && via[curX + 1][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY - 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX + 2, absY) and 0x12c01e0 == 0 && clipMaskSupplier.getClippingFlag(z, absX + 2, absY - 1) and 0x12c0183 == 0) {
- check(curX + 1, curY - 1, SOUTH_EAST_FLAG, thisCost)
- }
- if (curX < 102 && curY < 102 && via[curX + 1][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY + 2) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX + 2, absY + 2) and 0x12c01e0 == 0 && clipMaskSupplier.getClippingFlag(z, absX + 2, absY + 1) and 0x12c0183 == 0) {
- check(curX + 1, curY + 1, NORTH_EAST_FLAG, thisCost)
- }
- }
- }
-
- /**
- * Checks possible traversal for any sized entity.
- * @param end The destination location.
- * @param size The mover size.
- * @param sizeX The x-size of the destination.
- * @param sizeY The y-size of the destination.
- * @param type The object type.
- * @param rotation The object rotation.
- * @param walkingFlag The walking flag.
- * @param location The viewport location.
- */
- private fun checkVariableTraversal(end: Location, size: Int, sizeX: Int, sizeY: Int, type: Int, rotation: Int, walkingFlag: Int, location: Location, clipMaskSupplier: ClipMaskSupplier) {
- var readPosition = 0
- val z = location.z
- main@ while (writePathPosition != readPosition) {
- curX = queueX[readPosition]
- curY = queueY[readPosition]
- readPosition = readPosition + 1 and 0xfff
- if (curX == dstX && curY == dstY) {
- foundPath = true
- break
- }
- val absX = location.x + curX
- val absY = location.y + curY
- if (type != 0) {
- if ((type < 5 || type == 10) && canDoorInteract(absX, absY, size, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- if (type < 10 && canDecorationInteract(absX, absY, size, end.x, end.y, type - 1, rotation, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- }
- if (sizeX != 0 && sizeY != 0 && canInteract(absX, absY, size, end.x, end.y, sizeX, sizeY, walkingFlag, z, clipMaskSupplier)) {
- foundPath = true
- break
- }
- val thisCost = cost[curX][curY] + 1
- south@ do {
- if (curY > 0 && via[curX][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY - 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX + (size - 1), absY - 1) and 0x12c0183 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX + i, absY - 1) and 0x12c018f != 0) {
- break@south
- }
- }
- check(curX, curY - 1, SOUTH_FLAG, thisCost)
- }
- } while (false)
- west@ do {
- if (curX > 0 && via[curX - 1][curY] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + (size - 1)) and 0x12c0138 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX - 1, absY + i) and 0x12c013e != 0) {
- break@west
- }
- }
- check(curX - 1, curY, WEST_FLAG, thisCost)
- }
- } while (false)
- north@ do {
- if (curY < 102 && via[curX][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY + size) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX + (size - 1), absY + size) and 0x12c01e0 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX + i, absY + size) and 0x12c01f8 != 0) {
- break@north
- }
- }
- check(curX, curY + 1, NORTH_FLAG, thisCost)
- }
- } while (false)
- east@ do {
- if (curX < 102 && via[curX + 1][curY] == 0 && clipMaskSupplier.getClippingFlag(z, absX + size, absY) and 0x12c0183 == 0 && clipMaskSupplier.getClippingFlag(z, absX + size, absY + (size - 1)) and 0x12c01e0 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX + size, absY + i) and 0x12c01e3 != 0) {
- break@east
- }
- }
- check(curX + 1, curY, EAST_FLAG, thisCost)
- }
- } while (false)
- southWest@ do {
- if (curX > 0 && curY > 0 && via[curX - 1][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + (size - 2)) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY - 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX + (size - 2), absY - 1) and 0x12c0183 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX - 1, absY + (i - 1)) and 0x12c013e != 0 || clipMaskSupplier.getClippingFlag(z, absX + (i - 1), absY - 1) and 0x12c018f != 0) {
- break@southWest
- }
- }
- check(curX - 1, curY - 1, SOUTH_WEST_FLAG, thisCost)
- }
- } while (false)
- northWest@ do {
- if (curX > 0 && curY < 102 && via[curX - 1][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX - 1, absY + size) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX, absY + size) and 0x12c01e0 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX - 1, absY + (i + 1)) and 0x12c013e != 0 || clipMaskSupplier.getClippingFlag(z, absX + (i - 1), absY + size) and 0x12c01f8 != 0) {
- break@northWest
- }
- }
- check(curX - 1, curY + 1, NORTH_WEST_FLAG, thisCost)
- }
- } while (false)
- southEast@ do {
- if (curX < 102 && curY > 0 && via[curX + 1][curY - 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY - 1) and 0x12c010e == 0 && clipMaskSupplier.getClippingFlag(z, absX + size, absY - 1) and 0x12c0183 == 0 && clipMaskSupplier.getClippingFlag(z, absX + size, absY + (size - 2)) and 0x12c01e0 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX + size, absY + (i - 1)) and 0x12c01e3 != 0 || clipMaskSupplier.getClippingFlag(z, absX + (i + 1), absY - 1) and 0x12c018f != 0) {
- break@southEast
- }
- }
- check(curX + 1, curY - 1, SOUTH_EAST_FLAG, thisCost)
- }
- } while (false)
- if (curX < 102 && curY < 102 && via[curX + 1][curY + 1] == 0 && clipMaskSupplier.getClippingFlag(z, absX + 1, absY + size) and 0x12c0138 == 0 && clipMaskSupplier.getClippingFlag(z, absX + size, absY + size) and 0x12c01e0 == 0 && clipMaskSupplier.getClippingFlag(z, absX + size, absY + 1) and 0x12c0183 == 0) {
- for (i in 1 until size - 1) {
- if (clipMaskSupplier.getClippingFlag(z, absX + (i + 1), absY + size) and 0x12c01f8 != 0 || clipMaskSupplier.getClippingFlag(z, absX + size, absY + (i + 1)) and 0x12c01e3 != 0) {
- continue@main
- }
- }
- check(curX + 1, curY + 1, NORTH_EAST_FLAG, thisCost)
- }
- }
- }
-}
diff --git a/Server/src/main/core/game/worldevents/holiday/easter/EasterEvent.kt b/Server/src/main/core/game/worldevents/holiday/easter/EasterEvent.kt
index 6b129b906..7dcae1c12 100644
--- a/Server/src/main/core/game/worldevents/holiday/easter/EasterEvent.kt
+++ b/Server/src/main/core/game/worldevents/holiday/easter/EasterEvent.kt
@@ -296,7 +296,9 @@ class EasterEvent : WorldEvent("easter"), TickListener, InteractionListener, Log
val dir = dirs[RandomFunction.random(dirs.size)]
var loc = player.location.transform(dir, 3)
val path = Pathfinder.find(player, loc)
- loc = Location.create(path.points.last.x, path.points.last.y, loc.z)
+ path.points.lastOrNull()?.let {
+ loc = Location.create(it.x, it.y, loc.z)
+ }
GroundItemManager.create(Item(eggs.random()), loc, player)
sendMessage(player, colorize("%RAn egg has appeared nearby."))
}
@@ -317,4 +319,4 @@ class EasterEvent : WorldEvent("easter"), TickListener, InteractionListener, Log
WeightedItem(Items.DRAGON_IMPLING_JAR_11256, 1, 1, 0.005)
)
}
-}
\ No newline at end of file
+}
diff --git a/Server/src/main/core/net/packet/PacketProcessor.kt b/Server/src/main/core/net/packet/PacketProcessor.kt
index c05798490..b839aade1 100644
--- a/Server/src/main/core/net/packet/PacketProcessor.kt
+++ b/Server/src/main/core/net/packet/PacketProcessor.kt
@@ -71,6 +71,12 @@ object PacketProcessor {
}
}
+ @JvmStatic fun clearQueue() {
+ synchronized(queueLock) {
+ queue.clear()
+ }
+ }
+
@JvmStatic fun processQueue() {
synchronized(queueLock) {
if (queue.isEmpty()) {
diff --git a/Server/src/main/core/worker/MajorUpdateWorker.kt b/Server/src/main/core/worker/MajorUpdateWorker.kt
index 29d48faa8..e9e43ba23 100644
--- a/Server/src/main/core/worker/MajorUpdateWorker.kt
+++ b/Server/src/main/core/worker/MajorUpdateWorker.kt
@@ -5,6 +5,7 @@ import core.ServerConstants
import core.ServerStore
import core.api.log
import core.api.submitWorldPulse
+import core.game.node.entity.combat.CombatMovementIntents
import core.game.system.task.Pulse
import core.game.world.GameWorld
import core.game.world.repository.Repository
@@ -20,6 +21,7 @@ import java.lang.Long.max
import java.text.SimpleDateFormat
import java.util.*
import kotlin.system.exitProcess
+import kotlin.system.measureTimeMillis
/**
* Handles the running of pulses and writing of masks, etc
@@ -118,6 +120,13 @@ class MajorUpdateWorker {
GameWorld.Pulser.updateAll()
}
GameWorld.tickListeners.forEach { it.tick() }
+ val meleePressureTime = measureTimeMillis {
+ CombatMovementIntents.requestActiveMeleePressure()
+ }
+ val movementResolveTime = measureTimeMillis {
+ CombatMovementIntents.resolve()
+ }
+ notifyIfCombatMovementTooLong(meleePressureTime, movementResolveTime)
sequence.start()
sequence.run()
@@ -138,6 +147,29 @@ class MajorUpdateWorker {
}
}
+ private fun notifyIfCombatMovementTooLong(meleePressureTime: Long, movementResolveTime: Long) {
+ val totalTime = meleePressureTime + movementResolveTime
+ val resolveSummary = CombatMovementIntents.lastResolveSummary()
+ if (totalTime >= CRITICAL_COMBAT_MOVEMENT_MS) {
+ log(
+ this::class.java,
+ Log.WARN,
+ "CRITICALLY long combat movement update - requestActiveMeleePressure took $meleePressureTime ms, resolve took $movementResolveTime ms, total $totalTime ms, $resolveSummary"
+ )
+ } else if (totalTime >= LONG_COMBAT_MOVEMENT_MS) {
+ log(
+ this::class.java,
+ Log.WARN,
+ "Long combat movement update - requestActiveMeleePressure took $meleePressureTime ms, resolve took $movementResolveTime ms, total $totalTime ms, $resolveSummary"
+ )
+ }
+ }
+
+ companion object {
+ private const val LONG_COMBAT_MOVEMENT_MS = 50L
+ private const val CRITICAL_COMBAT_MOVEMENT_MS = 100L
+ }
+
fun start() {
if (!started) {
running = true
diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt
new file mode 100644
index 000000000..b48e7681e
--- /dev/null
+++ b/Server/src/test/kotlin/content/CombatMovementTests.kt
@@ -0,0 +1,2149 @@
+package content
+
+import MockSession
+import TestUtils
+import core.api.EquipmentSlot
+import core.game.global.action.DoorActionHandler
+import core.game.node.Node
+import core.game.node.entity.Entity
+import core.game.node.entity.combat.*
+import core.game.node.entity.combat.equipment.WeaponInterface
+import core.game.node.entity.combat.spell.CombatSpell
+import core.game.node.entity.combat.spell.SpellType
+import core.game.node.entity.npc.NPC
+import core.game.node.entity.player.Player
+import core.game.node.entity.skill.Skills
+import core.game.node.item.Item
+import core.game.node.scenery.SceneryBuilder
+import core.game.system.config.DoorConfigLoader
+import core.game.world.GameWorld
+import core.game.world.map.Location
+import core.game.world.map.RegionManager
+import core.game.world.map.path.Pathfinder
+import core.game.world.map.path.RsmodPathfinder
+import core.net.packet.PacketProcessor
+import core.net.packet.`in`.Packet
+import core.plugin.Plugin
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+import org.rs09.consts.Items
+import org.rsmod.game.pathfinder.flag.CollisionFlag
+import kotlin.math.abs
+
+class CombatMovementTests {
+ init {
+ TestUtils.preTestSetup()
+ }
+
+ @Test
+ fun meleeAttackerShouldMirrorRunningVictimWhenRunEnabledAndKeepAttackPressure() {
+ TestUtils.getMockPlayer("combat_mirror_attacker").use { attacker ->
+ TestUtils.getMockPlayer("combat_mirror_victim").use { victim ->
+ val origin = arenaOrigin()
+ place(attacker, origin)
+ place(victim, origin.transform(1, 0, 0))
+ configureMelee(attacker)
+ configureMelee(victim)
+ enableRun(attacker)
+ enableRun(victim)
+ enablePvp(attacker, victim)
+
+ attacker.attack(victim)
+ queueRun(victim, origin.transform(8, 0, 0))
+ TestUtils.advanceTicks(8, false)
+
+ assertTrue(attacker.properties.combatPulse.isAttacking)
+ assertTrue(
+ meleeReach(attacker, victim),
+ "Attacker should stay in melee reach while the victim is running.",
+ )
+ }
+ }
+ }
+
+ @Test
+ fun meleeAttackerShouldQueueRunStepWhenRunEnabledAndCurrentTargetIsLeavingMeleeRange() {
+ TestUtils.getMockPlayer("combat_active_mirror_attacker").use { attacker ->
+ TestUtils.getMockPlayer("combat_active_mirror_victim").use { victim ->
+ val origin = arenaOrigin()
+ place(attacker, origin)
+ place(victim, origin.transform(1, 0, 0))
+ configureMelee(attacker)
+ configureMelee(victim)
+ enableRun(attacker)
+ enableRun(victim)
+ enablePvp(attacker, victim)
+ attacker.playerFlags.setUpdateSceneGraph(false)
+ victim.playerFlags.setUpdateSceneGraph(false)
+ CombatMovementIntents.clear()
+
+ attacker.attack(victim)
+ queueRun(victim, origin.transform(8, 0, 0))
+ core.game.world.GameWorld.Pulser.updateAll()
+ CombatMovementIntents.resolve()
+ attacker.walkingQueue.update()
+ victim.walkingQueue.update()
+
+ assertTrue(
+ meleeReach(attacker, victim),
+ "Attacker should run with a target leaving melee range. " +
+ "attacker=${attacker.location}, victim=${victim.location}",
+ )
+ }
+ }
+ }
+
+ @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 ->
+ TestUtils.getMockPlayer("combat_packet_mirror_victim").use { victim ->
+ val origin = arenaOrigin()
+ place(attacker, origin)
+ place(victim, origin.transform(1, 0, 0))
+ configureMelee(attacker)
+ configureMelee(victim)
+ equipDragonScimitar(attacker)
+ equipDragonScimitar(victim)
+ enableRun(attacker)
+ enableRun(victim)
+ enablePvp(attacker, victim)
+ CombatMovementIntents.clear()
+ PacketProcessor.clearQueue()
+
+ TestUtils.advanceTicks(1, false)
+ CombatMovementIntents.clear()
+ attacker.attack(victim)
+ PacketProcessor.enqueue(Packet.WorldspaceWalk(victim, origin.x + 8, origin.y, true))
+ TestUtils.advanceTicks(1, false)
+
+ assertTrue(
+ meleeReach(attacker, victim),
+ "Attacker should mirror a live run-click on the tick it is queued. " +
+ "attacker=${attacker.location}, victim=${victim.location}",
+ )
+ }
+ }
+ }
+
+ @Test
+ fun mutualMeleeAttackersShouldApproachInsteadOfWaitingForTheOtherActor() {
+ TestUtils.getMockPlayer("combat_meet_a").use { first ->
+ TestUtils.getMockPlayer("combat_meet_b").use { second ->
+ val origin = arenaOrigin()
+ val firstStart = origin
+ val secondStart = origin.transform(6, 0, 0)
+ place(first, firstStart)
+ place(second, secondStart)
+ configureMelee(first)
+ configureMelee(second)
+ enablePvp(first, second)
+
+ first.attack(second)
+ second.attack(first)
+ TestUtils.advanceTicks(4, false)
+
+ assertTrue(first.properties.combatPulse.isAttacking)
+ assertTrue(second.properties.combatPulse.isAttacking)
+ assertNotEquals(
+ firstStart,
+ first.location,
+ "First attacker should step toward the target.",
+ )
+ assertNotEquals(
+ secondStart,
+ second.location,
+ "Second attacker should step toward the target.",
+ )
+ assertTrue(
+ first.location.getDistance(second.location) <
+ firstStart.getDistance(secondStart),
+ "Mutual melee combat should close distance instead of waiting indefinitely.",
+ )
+ }
+ }
+ }
+
+ @Test
+ fun meleeIntentShouldNotMoveAttackerAlreadyOnAttackTile() {
+ TestUtils.getMockPlayer("combat_already_adjacent_attacker").use { player ->
+ val origin = arenaOrigin()
+ val staleStep = origin.transform(1, 0, 0)
+ place(player, origin)
+ configureMelee(player)
+ disableRun(player)
+
+ val npc = NPC.create(100, origin.transform(0, 1, 0))
+ npc.init()
+ try {
+ configureMelee(npc)
+
+ player.attack(npc)
+ player.playerFlags.setUpdateSceneGraph(false)
+ queueWalk(player, staleStep)
+ CombatMovementIntents.clear()
+ CombatMovementIntents.request(player, npc)
+ CombatMovementIntents.resolve()
+ player.walkingQueue.update()
+
+ assertEquals(
+ origin,
+ player.location,
+ "A stale melee movement intent must not make an already-adjacent attacker sidestep.",
+ )
+ assertTrue(meleeReach(player, npc))
+ assertTrue(player.properties.combatPulse.isAttacking)
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun mutualMeleeMovementShouldNotOverrideQueuedStepThatKeepsAttackRange() {
+ TestUtils.getMockPlayer("combat_synced_step_attacker").use { player ->
+ val origin = arenaOrigin()
+ val playerStep = origin.transform(1, 0, 0)
+ val npcStep = origin.transform(1, 1, 0)
+ place(player, origin)
+ configureMelee(player)
+ disableRun(player)
+
+ val npc = NPC.create(100, origin.transform(0, 1, 0))
+ npc.init()
+ try {
+ configureMelee(npc)
+
+ player.attack(npc)
+ npc.attack(player)
+ player.playerFlags.setUpdateSceneGraph(false)
+ queueWalk(player, playerStep)
+ queueWalk(npc, npcStep)
+
+ assertFalse(
+ CombatMovementIntents.shouldMaintainMeleePressure(player, npc),
+ "The player's existing step should already keep melee range.",
+ )
+ assertFalse(
+ CombatMovementIntents.shouldMaintainMeleePressure(npc, player),
+ "The NPC's existing step should already keep melee range.",
+ )
+
+ CombatMovementIntents.clear()
+ CombatMovementIntents.requestActiveMeleePressure()
+ CombatMovementIntents.resolve()
+ npc.walkingQueue.update()
+ player.walkingQueue.update()
+
+ assertEquals(playerStep, player.location)
+ assertEquals(npcStep, npc.location)
+ assertTrue(meleeReach(player, npc))
+ assertTrue(meleeReach(npc, player))
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun meleePressureShouldSidestepWhenMovingTargetWillBeAdjacentBehindClipping() {
+ TestUtils.getMockPlayer("combat_clipped_pressure_attacker").use { attacker ->
+ TestUtils.getMockPlayer("combat_clipped_pressure_victim").use { victim ->
+ val origin = arenaOrigin()
+ val currentVictim = origin.transform(1, 0, 0)
+ val predictedVictim = origin.transform(0, 1, 0)
+ place(attacker, origin)
+ place(victim, currentVictim)
+ configureMelee(attacker)
+ configureMelee(victim)
+ enableRun(attacker)
+ enableRun(victim)
+ enablePvp(attacker, victim)
+ CombatMovementIntents.clear()
+
+ attacker.attack(victim)
+ queueWalk(victim, predictedVictim)
+ RegionManager.addClippingFlag(
+ predictedVictim.z,
+ predictedVictim.x,
+ predictedVictim.y,
+ false,
+ Pathfinder.PREVENT_NORTH,
+ )
+ // Real map walls flag both edge tiles; melee reach reads the attacker-side tile.
+ RegionManager.addClippingFlag(
+ origin.z,
+ origin.x,
+ origin.y,
+ false,
+ CollisionFlag.WALL_NORTH,
+ )
+ RegionManager.addClippingFlag(
+ predictedVictim.z,
+ predictedVictim.x,
+ predictedVictim.y,
+ true,
+ CollisionFlag.WALL_SOUTH_PROJECTILE_BLOCKER,
+ )
+
+ try {
+ assertTrue(
+ CombatMovementIntents.shouldMaintainMeleePressure(attacker, victim),
+ "A moving target's clipped predicted side should request melee pressure movement.",
+ )
+ GameWorld.Pulser.updateAll()
+ assertTrue(
+ CombatMovementIntents.pendingCount() > 0,
+ "Combat pulse should queue a movement intent for the clipped predicted side.",
+ )
+ CombatMovementIntents.resolve()
+ attacker.playerFlags.setUpdateSceneGraph(false)
+ victim.playerFlags.setUpdateSceneGraph(false)
+ victim.walkingQueue.update()
+ attacker.walkingQueue.update()
+
+ assertNotEquals(
+ origin,
+ attacker.location,
+ "Melee pressure should not stay on a clipped side of the moving target. " +
+ CombatMovementIntents.lastResolveSummary() +
+ " queue=${attacker.walkingQueue.queue.size}",
+ )
+ assertTrue(
+ attacker.properties.combatPulse.isAttacking,
+ "Combat should remain active while sidestepping a clipped predicted attack side.",
+ )
+ assertTrue(
+ meleeReach(attacker, victim),
+ "Attacker should end the tick on an attackable side of the moving target. " +
+ "attacker=${attacker.location}, victim=${victim.location}",
+ )
+ } finally {
+ RegionManager.removeClippingFlag(
+ predictedVictim.z,
+ predictedVictim.x,
+ predictedVictim.y,
+ false,
+ Pathfinder.PREVENT_NORTH,
+ )
+ RegionManager.removeClippingFlag(
+ origin.z,
+ origin.x,
+ origin.y,
+ false,
+ CollisionFlag.WALL_NORTH,
+ )
+ RegionManager.removeClippingFlag(
+ predictedVictim.z,
+ predictedVictim.x,
+ predictedVictim.y,
+ true,
+ CollisionFlag.WALL_SOUTH_PROJECTILE_BLOCKER,
+ )
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun meleeAttackerShouldPathAroundFenceToReachAdjacentTarget() {
+ TestUtils.getMockPlayer("combat_fence_walkaround_attacker").use { player ->
+ val origin = arenaOrigin()
+ val npcLocation = origin.transform(0, 1, 0)
+ place(player, origin)
+ configureMelee(player)
+ disableRun(player)
+
+ val npc = stationaryNpc(100, npcLocation)
+ try {
+ configureMelee(npc)
+ // A fence blocks movement on the shared edge but not projectiles.
+ setFence(origin, npcLocation, add = true)
+
+ TestUtils.advanceTicks(1, true)
+ CombatMovementIntents.clear()
+ player.playerFlags.lastSceneGraph = origin
+ player.playerFlags.setUpdateSceneGraph(false)
+ player.attack(npc)
+ TestUtils.advanceTicks(5, false)
+
+ assertNotEquals(
+ origin,
+ player.location,
+ "A melee attacker blocked by a fence must walk around it instead of standing still. " +
+ "player=${player.location}, npc=$npcLocation, " +
+ CombatMovementIntents.lastResolveSummary(),
+ )
+ assertTrue(
+ CombatReach.hasMeleeReach(
+ player.location,
+ player.size(),
+ npc.location,
+ npc.size(),
+ ),
+ "The attacker should end on a side tile with melee reach. " +
+ "player=${player.location}, npc=${npc.location}",
+ )
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ setFence(origin, npcLocation, add = false)
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun meleeAttackerShouldReportUnreachableWhenTargetIsFencedInOnAllSides() {
+ TestUtils.getMockPlayer("combat_fence_enclosed_attacker").use { player ->
+ val origin = arenaOrigin()
+ val npcLocation = origin.transform(0, 1, 0)
+ val fencedNeighbours =
+ listOf(
+ npcLocation.transform(0, 1, 0),
+ npcLocation.transform(0, -1, 0),
+ npcLocation.transform(1, 0, 0),
+ npcLocation.transform(-1, 0, 0),
+ )
+ place(player, origin)
+ configureMelee(player)
+ disableRun(player)
+
+ val npc = stationaryNpc(100, npcLocation)
+ try {
+ configureMelee(npc)
+ for (neighbour in fencedNeighbours) {
+ setFence(npcLocation, neighbour, add = true)
+ }
+
+ TestUtils.advanceTicks(1, true)
+ CombatMovementIntents.clear()
+ player.playerFlags.lastSceneGraph = origin
+ player.playerFlags.setUpdateSceneGraph(false)
+ player.attack(npc)
+ TestUtils.advanceTicks(2, false)
+
+ assertTrue(
+ receivedMessage(player, "I can't reach that!"),
+ "A target fenced in on every side must report unreachable. " +
+ "player=${player.location}, " + CombatMovementIntents.lastResolveSummary(),
+ )
+ assertFalse(
+ player.properties.combatPulse.isAttacking,
+ "Combat must stop against a fully fenced-in target.",
+ )
+ assertEquals(
+ origin,
+ player.location,
+ "The attacker must not wander around an unreachable fenced-in target.",
+ )
+ } finally {
+ for (neighbour in fencedNeighbours) {
+ setFence(npcLocation, neighbour, add = false)
+ }
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun overlappingMeleePlayerShouldStepToOpenAttackTileInsteadOfStopping() {
+ TestUtils.getMockPlayer("combat_overlap_player_escape").use { player ->
+ val origin = arenaOrigin()
+ val blockedTiles =
+ listOf(
+ origin.transform(0, 1, 0),
+ origin.transform(1, 0, 0),
+ )
+ place(player, origin)
+ configureMelee(player)
+ disableRun(player)
+
+ val npc = NPC.create(100, origin)
+ npc.init()
+ try {
+ configureMelee(npc)
+ blockMovementTiles(blockedTiles)
+
+ TestUtils.advanceTicks(1, true)
+ CombatMovementIntents.clear()
+ player.playerFlags.lastSceneGraph = origin
+ player.playerFlags.setUpdateSceneGraph(false)
+ player.attack(npc)
+ TestUtils.advanceTicks(1, false)
+
+ assertTrue(
+ player.location == origin.transform(-1, 0, 0) ||
+ player.location == origin.transform(0, -1, 0),
+ "An overlapped melee player should step to the open west/south side instead of stopping. " +
+ "player=${player.location}, npc=${npc.location}",
+ )
+ assertTrue(CombatReach.canMelee(player, npc, CombatReach.meleeDistance(player)))
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ unblockMovementTiles(blockedTiles)
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun overlappingMeleeNpcShouldStepToOpenAttackTileInsteadOfStalling() {
+ TestUtils.getMockPlayer("combat_overlap_npc_target").use { player ->
+ val origin = arenaOrigin()
+ val blockedTiles =
+ listOf(
+ origin.transform(0, 1, 0),
+ origin.transform(1, 0, 0),
+ )
+ place(player, origin)
+ configureMelee(player)
+ disableRun(player)
+
+ val npc = NPC.create(100, origin)
+ npc.init()
+ try {
+ configureMelee(npc)
+ blockMovementTiles(blockedTiles)
+
+ npc.attack(player)
+ TestUtils.advanceTicks(1, false)
+
+ assertTrue(
+ npc.location == origin.transform(-1, 0, 0) ||
+ npc.location == origin.transform(0, -1, 0),
+ "An overlapped dumb melee NPC should try the open west/south side, not only north/east. " +
+ "npc=${npc.location}, player=${player.location}",
+ )
+ assertTrue(CombatReach.canMelee(npc, player, CombatReach.meleeDistance(npc)))
+ assertTrue(npc.properties.combatPulse.isAttacking)
+ } finally {
+ unblockMovementTiles(blockedTiles)
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun mutualOverlappedMeleeCombatShouldOnlyMoveOneActorIntoAttackRange() {
+ TestUtils.getMockPlayer("combat_overlap_mutual_player").use { player ->
+ val origin = arenaOrigin()
+ val blockedTiles =
+ listOf(
+ origin.transform(0, 1, 0),
+ origin.transform(1, 0, 0),
+ )
+ place(player, origin)
+ configureMelee(player)
+ disableRun(player)
+
+ val npc = NPC.create(100, origin)
+ npc.init()
+ try {
+ configureMelee(npc)
+ blockMovementTiles(blockedTiles)
+
+ TestUtils.advanceTicks(1, true)
+ CombatMovementIntents.clear()
+ player.playerFlags.lastSceneGraph = origin
+ player.playerFlags.setUpdateSceneGraph(false)
+ player.attack(npc)
+ npc.attack(player)
+ TestUtils.advanceTicks(1, false)
+
+ assertTrue(player.location != origin || npc.location != origin)
+ assertTrue(
+ player.location == origin || npc.location == origin,
+ "When both overlapped actors are attacking, one side stepping out is enough. " +
+ "player=${player.location}, npc=${npc.location}",
+ )
+ assertTrue(CombatReach.canMelee(player, npc, CombatReach.meleeDistance(player)))
+ assertTrue(CombatReach.canMelee(npc, player, CombatReach.meleeDistance(npc)))
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ unblockMovementTiles(blockedTiles)
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun playerShouldChaseMovingMeleeNpcWithoutGenericInteractionMovementPulse() {
+ TestUtils.getMockPlayer("combat_npc_chaser").use { player ->
+ val origin = arenaOrigin()
+ place(player, origin)
+ configureMelee(player)
+
+ val npc = NPC.create(100, origin.transform(4, 0, 0))
+ npc.init()
+ try {
+ configureMelee(npc)
+ queueRun(npc, origin.transform(10, 0, 0))
+
+ player.attack(npc)
+ TestUtils.advanceTicks(8, false)
+
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertTrue(
+ player.location.getDistance(npc.location) <= 2.0,
+ "Player should continue closing on a moving melee NPC target.",
+ )
+ } finally {
+ npc.clear()
+ }
+ }
+ }
+
+ @Test
+ fun runEnabledMeleePlayerShouldSwingBeforeWalkingNpcStopsMovingAway() {
+ TestUtils.getMockPlayer("combat_walking_npc_chaser").use { player ->
+ val origin = openHorizontalOrigin()
+ val destination = origin.transform(10, 0, 0)
+ place(player, origin)
+ configureMelee(player)
+ enableRun(player)
+ player.playerFlags.setUpdateSceneGraph(false)
+
+ val npc = NPC.create(100, origin.transform(4, 0, 0))
+ npc.init()
+ try {
+ configureMelee(npc)
+
+ player.attack(npc)
+ queueWalk(npc, destination)
+ TestUtils.advanceTicks(5, false)
+
+ assertNotEquals(
+ destination,
+ npc.location,
+ "The NPC should still be walking during this assertion.",
+ )
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertTrue(
+ player.properties.combatPulse.getNextAttack() > -1,
+ "Run-enabled melee chase should create an attack opportunity before the walking NPC stops. " +
+ "player=${player.location}, npc=${npc.location}, " +
+ CombatMovementIntents.lastResolveSummary(),
+ )
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun runningMeleePlayerShouldNotCrossNpcFootprintToReachFarSideAttackTile() {
+ TestUtils.getMockPlayer("combat_no_cross_footprint_attacker").use { player ->
+ val origin = openHorizontalOrigin()
+ val npcLocation = origin.transform(2, 0, 0)
+ val blockedTiles =
+ listOf(
+ npcLocation.transform(0, 1, 0),
+ npcLocation.transform(0, -1, 0),
+ )
+ place(player, origin)
+ configureMelee(player)
+ enableRun(player)
+ player.playerFlags.setUpdateSceneGraph(false)
+
+ val npc = NPC.create(100, npcLocation)
+ npc.init()
+ val westTile = npcLocation.transform(-1, 0, 0)
+ val farSideTile = npcLocation.transform(1, 0, 0)
+ try {
+ configureMelee(npc)
+ blockMovementTiles(blockedTiles)
+ setFence(westTile, npcLocation, add = true)
+ RegionManager.addClippingFlag(
+ npcLocation.z,
+ npcLocation.x,
+ npcLocation.y,
+ true,
+ CollisionFlag.WALL_WEST_PROJECTILE_BLOCKER,
+ )
+
+ player.attack(npc)
+ CombatMovementIntents.clear()
+ repeat(5) {
+ CombatMovementIntents.request(player, npc)
+ CombatMovementIntents.resolve()
+ player.walkingQueue.update()
+ assertNotEquals(
+ npcLocation,
+ player.location,
+ "The player must not run onto the NPC footprint. " +
+ "player=${player.location}, ${CombatMovementIntents.lastResolveSummary()}",
+ )
+ }
+
+ assertEquals(
+ farSideTile,
+ player.location,
+ "A walled-off near side must route the player to the far-side attack tile. " +
+ "player=${player.location}, npc=$npcLocation, ${CombatMovementIntents.lastResolveSummary()}",
+ )
+ } finally {
+ RegionManager.removeClippingFlag(
+ npcLocation.z,
+ npcLocation.x,
+ npcLocation.y,
+ true,
+ CollisionFlag.WALL_WEST_PROJECTILE_BLOCKER,
+ )
+ setFence(westTile, npcLocation, add = false)
+ unblockMovementTiles(blockedTiles)
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun movementLockedMeleeAttackerShouldNotMoveButCanAttackIfAlreadyInRange() {
+ TestUtils.getMockPlayer("combat_locked_attacker").use { attacker ->
+ TestUtils.getMockPlayer("combat_locked_victim").use { victim ->
+ val origin = arenaOrigin()
+ place(attacker, origin)
+ place(victim, origin.transform(1, 0, 0))
+ configureMelee(attacker)
+ configureMelee(victim)
+ enablePvp(attacker, victim)
+ attacker.locks.lockMovement(10)
+
+ attacker.attack(victim)
+ TestUtils.advanceTicks(2, false)
+
+ assertEquals(
+ origin,
+ attacker.location,
+ "Movement lock should prevent combat chase movement.",
+ )
+ assertTrue(
+ attacker.properties.combatPulse.isAttacking,
+ "Movement lock should not prevent an in-range melee swing.",
+ )
+ }
+ }
+ }
+
+ @Test
+ fun meleeReachShouldUseOccupiedTilesForLargeTargets() {
+ TestUtils.getMockPlayer("combat_large_target_attacker").use { player ->
+ val origin = arenaOrigin()
+ place(player, origin)
+ configureMelee(player)
+
+ val npc = NPC.create(100, origin.transform(4, 0, 0))
+ npc.setSize(2)
+ npc.init()
+ try {
+ configureMelee(npc)
+
+ player.attack(npc)
+ TestUtils.advanceTicks(6, false)
+
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertTrue(
+ player.location.getDistance(npc.getClosestOccupiedTile(player.location)) <= 1.0,
+ "Melee reach should be measured against the large target's occupied border.",
+ )
+ } finally {
+ npc.clear()
+ }
+ }
+ }
+
+ @Test
+ fun staleRangedCombatTargetAcrossMapShouldStopBeforePathfinding() {
+ TestUtils.getMockPlayer("combat_stale_seercull_attacker").use { player ->
+ val seers = Location.create(2726, 3485, 0)
+ val lumbridge = Location.create(3216, 3209, 0)
+ place(player, seers)
+ configureRanged(player)
+
+ val npc = NPC.create(100, lumbridge)
+ npc.init()
+ try {
+ player.attack(npc)
+ TestUtils.advanceTicks(1, false)
+
+ assertFalse(
+ player.properties.combatPulse.isAttacking,
+ "Cross-map combat targets should be discarded before combat movement pathfinding.",
+ )
+ assertEquals(seers, player.location)
+ assertTrue(
+ player.walkingQueue.queue.size <= 1,
+ "Stopping stale combat should not queue a path toward the old target.",
+ )
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun teleportShouldClearActiveCombatTarget() {
+ TestUtils.getMockPlayer("combat_teleporting_attacker").use { player ->
+ val lumbridge = Location.create(3216, 3209, 0)
+ val seers = Location.create(2726, 3485, 0)
+ place(player, lumbridge)
+ configureRanged(player)
+
+ val npc = NPC.create(100, lumbridge.transform(2, 0, 0))
+ npc.init()
+ try {
+ player.attack(npc)
+ assertTrue(player.properties.combatPulse.isAttacking)
+
+ player.properties.teleportLocation = seers
+ player.walkingQueue.update()
+
+ assertEquals(seers, player.location)
+ assertFalse(
+ player.properties.combatPulse.isAttacking,
+ "Teleporting should discard the previous combat target.",
+ )
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun rangedPlayerShouldPathTowardLumbridgeRiverDuckBeforeAttacking() {
+ TestUtils.getMockPlayer("combat_ranged_duck_attacker").use { player ->
+ val duckLocation = Location.create(3238, 3244, 0)
+ val start = walkableTileNear(duckLocation, minDistance = 11, maxRadius = 16)
+ place(player, start)
+ configureRanged(player)
+ enableRun(player)
+
+ val duck = stationaryNpc(46, duckLocation)
+ try {
+ assertTrue(
+ RegionManager.isTeleportPermitted(start),
+ "Test start tile must be walkable.",
+ )
+ assertFalse(
+ RegionManager.isTeleportPermitted(duckLocation),
+ "Lumbridge river duck spawn should be on water.",
+ )
+
+ val startDistance = start.getDistance(duck.location)
+ player.attack(duck)
+ TestUtils.advanceTicks(1, false)
+
+ assertTrue(
+ player.properties.combatPulse.isAttacking,
+ "Ranged combat should keep chasing a water NPC instead of rejecting before movement.",
+ )
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+
+ TestUtils.advanceTicks(3, false)
+
+ assertTrue(
+ player.location.getDistance(duck.location) < startDistance,
+ "Player should path toward ranged attack range.",
+ )
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ TestUtils.advanceTicks(8, false)
+
+ assertTrue(
+ player.properties.combatPulse.isAttacking,
+ "Ranged combat should remain active after moving into range.",
+ )
+ assertTrue(
+ player.location.getDistance(duck.location) <= 10.0,
+ "Player should stop once close enough to attack the duck from land.",
+ )
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ duck.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun rangedMovementShouldTryAnotherApproachBatchWhenTheFirstIsUnrouteable() {
+ TestUtils.getMockPlayer("combat_ranged_later_batch").use { player ->
+ val origin = openHorizontalOrigin()
+ val npcLocation = origin.transform(6, 0, 0)
+ place(player, origin)
+ configureRanged(player)
+ disableRun(player)
+ player.playerFlags.setUpdateSceneGraph(false)
+
+ val npc = stationaryNpc(100, npcLocation)
+ val projectileFlag = CollisionFlag.WALL_WEST_PROJECTILE_BLOCKER
+ val fencedEdges = ArrayList>()
+ try {
+ RegionManager.addClippingFlag(
+ npcLocation.z,
+ npcLocation.x,
+ npcLocation.y,
+ true,
+ projectileFlag,
+ )
+ assertFalse(
+ CombatSwingHandler.isProjectileClipped(player, npc, false),
+ "The starting tile must be in nominal range but projectile-blocked.",
+ )
+
+ val orderedCandidates = rangedApproachCandidates(player, npc, range = 7)
+ assertTrue(
+ orderedCandidates.size > 16,
+ "The fixture needs a routeable ranged candidate after the first batch.",
+ )
+ val enclosedTiles =
+ LinkedHashSet().apply {
+ addAll(orderedCandidates.take(32))
+ addAll(
+ CombatMovementPlanner.candidateAttackTiles(
+ player,
+ npc,
+ npc.location,
+ )
+ )
+ }
+ fencedEdges.addAll(perimeterEdges(enclosedTiles))
+ for ((inside, outside) in fencedEdges) {
+ setFence(inside, outside, add = true)
+ }
+
+ val fencedCandidates = rangedApproachCandidates(player, npc, range = 7)
+ val firstBatch = fencedCandidates.take(16)
+ assertTrue(
+ firstBatch.none { candidate ->
+ Pathfinder.SMART.find(
+ player.location,
+ player.size(),
+ candidate,
+ 0,
+ 0,
+ 0,
+ -1,
+ 0,
+ false,
+ null,
+ ).isSuccessful
+ },
+ "Every candidate in the first ranged batch must be unrouteable.",
+ )
+ player.attack(npc)
+ TestUtils.advanceTicks(1, false)
+
+ val summary = CombatMovementIntents.lastResolveSummary()
+ val routeCalls =
+ Regex("rsmodRouteCalls=(\\d+)")
+ .find(summary)
+ ?.groupValues
+ ?.get(1)
+ ?.toInt()
+ ?: 0
+ assertTrue(
+ routeCalls >= 32,
+ "Ranged movement must continue checking routes after the first 16 fail. $summary",
+ )
+ } finally {
+ for ((inside, outside) in fencedEdges) {
+ setFence(inside, outside, add = false)
+ }
+ RegionManager.removeClippingFlag(
+ npcLocation.z,
+ npcLocation.x,
+ npcLocation.y,
+ true,
+ projectileFlag,
+ )
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun borkLegionShouldAttackFromItsAuthenticTwoTileMeleeReach() {
+ TestUtils.getMockPlayer("combat_bork_legion_target").use { player ->
+ val origin = openHorizontalOrigin()
+ val legionLocation = origin.transform(2, 0, 0)
+ place(player, origin)
+
+ val legion = NPC.create(7135, legionLocation, player)
+ legion.init()
+ try {
+ configureMelee(legion)
+
+ assertTrue(CombatReach.hasExtendedMeleeReach(legion))
+ assertEquals(2, CombatReach.meleeDistance(legion))
+ assertEquals(
+ InteractionType.STILL_INTERACT,
+ legion.getSwingHandler(false).canSwing(legion, player),
+ "Bork's legion should be able to swing from two clear tiles away.",
+ )
+
+ legion.attack(player)
+ CombatMovementIntents.clear()
+ CombatMovementIntents.request(legion, player)
+ CombatMovementIntents.resolve()
+ legion.walkingQueue.update()
+
+ assertEquals(
+ legionLocation,
+ legion.location,
+ "The combat movement intent must not force Bork's legion into adjacency.",
+ )
+ assertTrue(legion.properties.combatPulse.isAttacking)
+ } finally {
+ legion.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun magicAutocastShouldPathToProjectileClearLumbridgeRiverDuckTile() {
+ TestUtils.getMockPlayer("combat_magic_duck_attacker").use { player ->
+ val duckLocation = Location.create(3238, 3244, 0)
+ val start = walkableTileNear(duckLocation, minDistance = 11, maxRadius = 16)
+ place(player, start)
+ configureMagicAutocast(player)
+ enableRun(player)
+
+ val duck = stationaryNpc(46, duckLocation)
+ try {
+ assertTrue(
+ RegionManager.isTeleportPermitted(start),
+ "Test start tile must be walkable.",
+ )
+ assertFalse(
+ RegionManager.isTeleportPermitted(duckLocation),
+ "Lumbridge river duck spawn should be on water.",
+ )
+
+ player.attack(duck)
+ TestUtils.advanceTicks(12, false)
+
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertTrue(
+ magicReach(player, duck),
+ "Autocast should stop on a projectile-clear tile instead of dancing within blocked range. " +
+ "player=${player.location}, duck=${duck.location}, distance=${
+ player.location.getDistance(
+ duck.location
+ )
+ }",
+ )
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ duck.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun magicAutocastShouldNotLoopWhenLumbridgeRiverDuckIsOnlyInBlockedRange() {
+ TestUtils.getMockPlayer("combat_magic_blocked_duck_attacker").use { player ->
+ val start = Location.create(3240, 3242, 0)
+ val duckLocation = Location.create(3235, 3259, 0)
+ place(player, start)
+ configureMagicAutocast(player)
+ enableRun(player)
+
+ val duck = stationaryNpc(46, duckLocation)
+ try {
+ assertTrue(
+ RegionManager.isTeleportPermitted(start),
+ "Test start tile must be walkable.",
+ )
+ assertFalse(
+ RegionManager.isTeleportPermitted(duckLocation),
+ "Lumbridge river duck spawn should be on water.",
+ )
+
+ player.attack(duck)
+ TestUtils.advanceTicks(18, false)
+
+ assertFalse(
+ player.properties.combatPulse.isAttacking && !magicReach(player, duck),
+ "Autocast should not stay active while standing in projectile-blocked spell range. " +
+ "player=${player.location}, duck=${duck.location}, distance=${
+ player.location.getDistance(
+ duck.location
+ )
+ }, " +
+ "projectile=${CombatSwingHandler.isProjectileClipped(player, duck, false)}",
+ )
+ if (!player.properties.combatPulse.isAttacking) {
+ assertTrue(receivedMessage(player, "I can't reach that!"))
+ assertTrue(
+ player.walkingQueue.queue.size <= 1,
+ "Stopping blocked autocast should not leave a chase path queued.",
+ )
+ }
+ assertTrue(
+ player.location.getDistance(duck.location) <= start.getDistance(duck.location),
+ "Blocked autocast should not route away around the river before resolving combat.",
+ )
+ } finally {
+ duck.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun meleePlayerShouldPathTowardLumbridgeRiverDuckBeforeRejecting() {
+ TestUtils.getMockPlayer("combat_melee_duck_attacker").use { player ->
+ val duckLocation = Location.create(3238, 3244, 0)
+ val start = walkableTileNear(duckLocation, minDistance = 11, maxRadius = 16)
+ place(player, start)
+ configureMelee(player)
+ enableRun(player)
+
+ val duck = stationaryNpc(46, duckLocation)
+ try {
+ assertTrue(
+ RegionManager.isTeleportPermitted(start),
+ "Test start tile must be walkable.",
+ )
+ assertFalse(
+ RegionManager.isTeleportPermitted(duckLocation),
+ "Lumbridge river duck spawn should be on water.",
+ )
+
+ val startDistance = start.getDistance(duck.location)
+ player.attack(duck)
+ TestUtils.advanceTicks(1, false)
+
+ assertTrue(
+ player.properties.combatPulse.isAttacking,
+ "Melee combat should attempt to path before reporting that a water NPC cannot be reached.",
+ )
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+
+ TestUtils.advanceTicks(3, false)
+
+ assertTrue(
+ player.location.getDistance(duck.location) < startDistance,
+ "Player should path toward the duck before failing melee reach.",
+ )
+ TestUtils.advanceTicks(20, false)
+
+ assertFalse(player.properties.combatPulse.isAttacking)
+ assertTrue(
+ receivedMessage(player, "I can't reach that!"),
+ "Melee combat should report unreachable after pathing. " +
+ "location=${player.location}, distance=${
+ player.location.getDistance(duck.location)
+ }, " +
+ "isAttacking=${player.properties.combatPulse.isAttacking}",
+ )
+ assertTrue(
+ player.location.getDistance(duck.location) <= startDistance,
+ "Melee combat should not route away around the river before rejecting the duck.",
+ )
+ } finally {
+ duck.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun meleePlayerShouldRejectConstantlySwimmingDuckOnceApproachIsExhausted() {
+ TestUtils.getMockPlayer("combat_melee_moving_duck_attacker").use { player ->
+ val duckLocation = Location.create(3238, 3244, 0)
+ val swimTiles = listOf(duckLocation, duckLocation.transform(-1, 0, 0))
+ val start = walkableTileNear(duckLocation, minDistance = 8, maxRadius = 16)
+ place(player, start)
+ configureMelee(player)
+ enableRun(player)
+
+ val duck = stationaryNpc(46, duckLocation)
+ try {
+ assertTrue(
+ RegionManager.isTeleportPermitted(start),
+ "Test start tile must be walkable.",
+ )
+ for (tile in swimTiles) {
+ assertFalse(
+ RegionManager.isTeleportPermitted(tile),
+ "Lumbridge river duck swim tile should be on water.",
+ )
+ }
+
+ player.attack(duck)
+
+ var rejectedAfter = -1
+ for (tick in 0 until 30) {
+ queueWalk(duck, swimTiles[(tick + 1) % swimTiles.size])
+ assertTrue(
+ CombatMovementPlanner.hasMovementStepThisTick(duck),
+ "The duck must be moving every tick for this scenario.",
+ )
+ TestUtils.advanceTicks(1, false)
+ if (receivedMessage(player, "I can't reach that!")) {
+ rejectedAfter = tick
+ break
+ }
+ }
+
+ assertTrue(
+ rejectedAfter >= 0,
+ "Melee combat against a constantly swimming water NPC should be rejected once the " +
+ "walkable approach is exhausted instead of chasing forever. " +
+ "player=${player.location}, duck=${duck.location}",
+ )
+ assertTrue(
+ rejectedAfter <= 12,
+ "Rejection should happen shortly after the player reaches the river bank, " +
+ "not after a long futile chase (rejected after $rejectedAfter ticks).",
+ )
+ assertFalse(
+ player.properties.combatPulse.isAttacking,
+ "Combat should stop when the moving water NPC is rejected.",
+ )
+ assertTrue(
+ player.walkingQueue.queue.size <= 1,
+ "Stopping unreachable combat should not keep a movement path queued.",
+ )
+ } finally {
+ duck.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun unreachableCombatTargetShouldStopAndTellPlayer() {
+ TestUtils.getMockPlayer("combat_unreachable_target").use { player ->
+ val origin = arenaOrigin()
+ place(player, origin)
+ configureMelee(player)
+
+ val npc = NPC.create(100, origin.transform(4, 0, 0))
+ npc.init()
+ val blockedTiles = CombatMovementPlanner.borderTiles(npc, npc.location, player.size())
+ try {
+ blockMovementTiles(blockedTiles)
+
+ val startDistance = origin.getDistance(npc.location)
+ player.attack(npc)
+ TestUtils.advanceTicks(1, false)
+
+ assertTrue(
+ player.properties.combatPulse.isAttacking,
+ "Unreachable local combat targets should first path as close as possible.",
+ )
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+
+ TestUtils.advanceTicks(3, false)
+
+ assertTrue(
+ player.location.getDistance(npc.location) < startDistance,
+ "Player should move toward the nearest reachable tile first.",
+ )
+ TestUtils.advanceTicks(6, false)
+
+ assertFalse(
+ player.properties.combatPulse.isAttacking,
+ "Unreachable combat targets should stop once the closest reachable tile is reached.",
+ )
+ assertTrue(
+ player.walkingQueue.queue.size <= 1,
+ "Stopping unreachable combat should not keep a movement path queued.",
+ )
+ assertTrue(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ unblockMovementTiles(blockedTiles)
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun nearbyMeleeNpcShouldRetaliateAndPathWithoutDisconnectingPlayers() {
+ TestUtils.getMockPlayer("combat_retaliation_attacker").use { player ->
+ val origin = Location.create(3216, 3209, 0)
+ place(player, origin)
+ configureRanged(player)
+
+ val npc = NPC.create(100, origin.transform(8, 0, 0))
+ npc.init()
+ try {
+ val damage = BattleState(player, npc)
+ damage.estimatedHit = 1
+
+ npc.onImpact(player, damage)
+ TestUtils.advanceTicks(2, false)
+
+ assertTrue(npc.properties.combatPulse.isAttacking)
+ assertFalse((player.session as MockSession).disconnected)
+ assertTrue(
+ npc.location.getDistance(player.location) < 8.0,
+ "Retaliating NPC should step toward a nearby ranged attacker.",
+ )
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun npcRespawnShouldNotKeepPreviousPlayerCombatAggroState() {
+ TestUtils.getMockPlayer("combat_respawn_previous_player").use { player ->
+ val origin = arenaOrigin()
+ place(player, origin)
+ configureMelee(player)
+
+ val npc = NPC.create(100, origin.transform(1, 0, 0))
+ npc.init()
+ try {
+ configureMelee(npc)
+ npc.setAttribute("disable:drop", true)
+ npc.setAttribute("combat-attacker", player)
+ player.setAttribute("combat-attacker", npc)
+ player.setAttribute("aggressor", npc)
+ player.attack(npc)
+ npc.attack(player)
+
+ assertEquals(player, npc.getAttribute("combat-attacker"))
+ assertEquals(npc, player.getAttribute("combat-attacker"))
+ assertEquals(npc, player.getAttribute("aggressor"))
+
+ npc.finalizeDeath(npc)
+ npc.respawnTick = GameWorld.ticks
+ npc.isRespawning = true
+ npc.tick()
+
+ assertNull(npc.getAttribute("combat-attacker"))
+ assertNull(npc.getAttribute("aggressor"))
+ assertNull(player.getAttribute("combat-attacker"))
+ assertNull(player.getAttribute("aggressor"))
+ assertFalse(npc.properties.combatPulse.isAttacking)
+ assertFalse(npc.properties.combatPulse.isInCombat)
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun combatMovementShouldOnlyQueueImmediateMovementSteps() {
+ TestUtils.getMockPlayer("combat_short_queue_attacker").use { player ->
+ val origin = Location.create(3216, 3209, 0)
+ place(player, origin)
+ configureMelee(player)
+ enableRun(player)
+
+ val npc = NPC.create(100, origin.transform(8, 0, 0))
+ npc.init()
+ try {
+ player.attack(npc)
+ GameWorld.Pulser.updateAll()
+ CombatMovementIntents.resolve()
+
+ assertTrue(
+ player.walkingQueue.queue.size in 2..3,
+ "Combat movement should only queue the immediate movement step(s), not the full chase path.",
+ )
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun retaliatingNpcShouldNotForceRunTowardRunningPlayer() {
+ TestUtils.getMockPlayer("combat_npc_retaliation_target").use { player ->
+ val origin = arenaOrigin()
+ place(player, origin)
+ enableRun(player)
+ queueRun(player, origin.transform(12, 0, 0))
+
+ val npc = NPC.create(100, origin.transform(8, 0, 0))
+ npc.init()
+ try {
+ val damage = BattleState(player, npc)
+ damage.estimatedHit = 1
+
+ npc.onImpact(player, damage)
+ GameWorld.Pulser.updateAll()
+ CombatMovementIntents.resolve()
+ npc.walkingQueue.update()
+
+ assertNotEquals(-1, npc.walkingQueue.walkDir)
+ assertEquals(
+ -1,
+ npc.walkingQueue.runDir,
+ "NPC combat retaliation should walk, not force-run.",
+ )
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun defaultMeleeNpcShouldNotRouteAroundSafespotObstacle() {
+ 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 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 ->
+ val origin = arenaOrigin()
+ val blocker = origin.transform(0, 1, 0)
+ val npcStart = origin
+ place(player, origin.transform(0, 4, 0))
+
+ val npc = NPC.create(100, npcStart)
+ npc.init()
+ try {
+ configureMelee(npc)
+ blockMovementTiles(listOf(blocker))
+
+ npc.attack(player)
+ CombatMovementIntents.clear()
+ CombatMovementIntents.request(npc, player)
+ CombatMovementIntents.resolve()
+ npc.walkingQueue.update()
+
+ assertEquals(
+ npcStart,
+ npc.location,
+ "A dumb NPC should not route around a blocked next tile on the direct path to the player.",
+ )
+ assertTrue(npc.properties.combatPulse.isAttacking)
+ } finally {
+ unblockMovementTiles(listOf(blocker))
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun varrockGuardShouldStepAroundBakeryStallAfterRangedTargetRunsNorthEast() {
+ TestUtils.getMockPlayer("combat_varrock_bakery_target").use { player ->
+ val spawn = Location.create(2651, 3307, 0)
+ val originalTargetLocation = Location.create(2657, 3309, 0)
+ val guardStart = Location.create(2656, 3309, 0)
+ val targetDestination = Location.create(2658, 3311, 0)
+ place(player, originalTargetLocation)
+ configureRanged(player)
+ enableRun(player)
+
+ val npc = NPC.create(32, guardStart)
+ npc.init()
+ npc.properties.spawnLocation = spawn
+ try {
+ configureMelee(npc)
+
+ npc.attack(player)
+ queueRunPath(
+ player,
+ listOf(
+ Location.create(2658, 3309, 0),
+ Location.create(2658, 3310, 0),
+ targetDestination,
+ ),
+ )
+ TestUtils.advanceTicks(8, false)
+
+ assertNotEquals(
+ guardStart,
+ npc.location,
+ "The Varrock guard should not stall west of the player when the bakery stall blocks north. " +
+ "guard=${npc.location}, player=${player.location}, ${CombatMovementIntents.lastResolveSummary()}",
+ )
+ assertTrue(
+ npc.location.getDistance(player.location) <
+ originalTargetLocation.getDistance(targetDestination),
+ "The guard should keep taking local dumb steps toward the running target without full smart routing. " +
+ "guard=${npc.location}, player=${player.location}, ${CombatMovementIntents.lastResolveSummary()}",
+ )
+ assertTrue(npc.properties.combatPulse.isAttacking)
+ } finally {
+ 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()
+ }
+ }
+ }
+
+ @Test
+ fun meleePlayerShouldRouteAroundOpenedLumbridgeHouseDoorToReachNpc() {
+ TestUtils.getMockPlayer("combat_lumbridge_house_door_attacker").use { player ->
+ val start = Location.create(3230, 3236, 0)
+ val expectedAttackTile = Location.create(3231, 3237, 0)
+ val doorLocation = Location.create(3230, 3235, 0)
+ place(player, start)
+ configureMelee(player)
+ disableRun(player)
+
+ val door =
+ RegionManager.getObject(doorLocation.z, doorLocation.x, doorLocation.y, 36846)
+ ?: throw AssertionError("Expected Lumbridge house door 36846 at $doorLocation.")
+ assertEquals(1, door.rotation, "Expected the Lumbridge house door to face east.")
+ val doorConfig =
+ DoorConfigLoader.forId(door.id)
+ ?: throw AssertionError("Expected door config for ${door.id}.")
+ val openedDoorLocation = door.location.transform(0, 1, 0)
+ DoorActionHandler.open(
+ door,
+ null,
+ doorConfig.replaceId,
+ -1,
+ true,
+ -1,
+ doorConfig.isFence,
+ )
+
+ val npc = NPC.create(100, Location.create(3231, 3236, 0))
+ npc.init()
+ try {
+ configureMelee(npc)
+ player.attack(npc)
+ TestUtils.advanceTicks(6, false)
+
+ assertEquals(
+ expectedAttackTile,
+ player.location,
+ "Player should route north around the opened southern door instead of bouncing south.",
+ )
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertTrue(meleeReach(player, npc))
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ npc.clear()
+ val openedDoor =
+ RegionManager.getObject(
+ openedDoorLocation.z,
+ openedDoorLocation.x,
+ openedDoorLocation.y,
+ doorConfig.replaceId,
+ )
+ if (openedDoor != null) {
+ SceneryBuilder.replace(openedDoor, door)
+ }
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun meleePlayerShouldStepToNearestAttackableTileAtLumbridgeRuinCorner() {
+ TestUtils.getMockPlayer("combat_lumbridge_ruin_corner_attacker").use { player ->
+ val start = Location.create(3252, 3227, 0)
+ val npcLocation = Location.create(3253, 3226, 0)
+ val expectedAttackTiles =
+ setOf(
+ Location.create(3252, 3226, 0),
+ Location.create(3253, 3227, 0),
+ )
+ place(player, start)
+ configureMelee(player)
+ enableRun(player)
+ player.playerFlags.setUpdateSceneGraph(false)
+
+ val npc = NPC.create(1775, npcLocation)
+ npc.init()
+ try {
+ configureMelee(npc)
+
+ assertFalse(
+ CombatReach.canMelee(player, npc, CombatReach.meleeDistance(player)),
+ "The starting diagonal corner should be blocked for melee.",
+ )
+
+ player.attack(npc)
+ TestUtils.advanceTicks(2, false)
+
+ assertTrue(
+ player.location in expectedAttackTiles,
+ "Player should step to the nearest cardinal attack tile at the ruin corner, not route away. " +
+ "player=${player.location}, npc=${npc.location}, ${CombatMovementIntents.lastResolveSummary()}",
+ )
+ assertTrue(meleeReach(player, npc))
+ assertTrue(player.properties.combatPulse.isAttacking)
+ assertFalse(receivedMessage(player, "I can't reach that!"))
+ } finally {
+ npc.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ private fun arenaOrigin(): Location {
+ return Location.create(3200, 3600, 0)
+ }
+
+ private fun openHorizontalOrigin(): Location {
+ val start = arenaOrigin()
+ for (dy in -16..16) {
+ for (dx in -16..16) {
+ val candidate = start.transform(dx, dy, 0)
+ if (
+ (0..10).all { RegionManager.isTeleportPermitted(candidate.transform(it, 0, 0)) }
+ ) {
+ return candidate
+ }
+ }
+ }
+ throw AssertionError("No open horizontal combat test line found near $start.")
+ }
+
+ private fun place(entity: Entity, location: Location) {
+ entity.location = location
+ RegionManager.move(entity)
+ entity.walkingQueue.reset()
+ }
+
+ private fun queueRun(entity: Entity, destination: Location) {
+ entity.walkingQueue.reset(true)
+ entity.walkingQueue.addPath(destination.x, destination.y)
+ }
+
+ private fun queueWalk(entity: Entity, destination: Location) {
+ entity.walkingQueue.reset(false)
+ 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,
+ WeaponInterface.BONUS_CRUSH,
+ )
+ entity.properties.combatPulse.updateStyle()
+ }
+
+ private fun configureRanged(player: Player) {
+ player.equipment.replace(Item(6724), EquipmentSlot.WEAPON.ordinal)
+ player.equipment.replace(Item(Items.BRONZE_ARROW_882, 100), EquipmentSlot.AMMO.ordinal)
+ player.properties.attackStyle =
+ WeaponInterface.AttackStyle(
+ WeaponInterface.STYLE_RANGE_ACCURATE,
+ WeaponInterface.BONUS_RANGE,
+ )
+ player.properties.combatPulse.updateStyle()
+ }
+
+ private fun configureMagicAutocast(player: Player) {
+ player.equipment.replace(Item(Items.STAFF_OF_AIR_1381), EquipmentSlot.WEAPON.ordinal)
+ player.skills.setStaticLevel(Skills.MAGIC, 99)
+ player.skills.setLevel(Skills.MAGIC, 99)
+ player.properties.autocastSpell = TestAutocastSpell
+ player.properties.attackStyle =
+ WeaponInterface.AttackStyle(
+ WeaponInterface.STYLE_CAST,
+ WeaponInterface.BONUS_MAGIC,
+ )
+ player.properties.combatPulse.updateStyle()
+ }
+
+ private fun equipDragonScimitar(player: Player) {
+ player.equipment.replace(Item(Items.DRAGON_SCIMITAR_4587), EquipmentSlot.WEAPON.ordinal)
+ }
+
+ private fun enableRun(player: Player) {
+ player.settings.runEnergy = 100.0
+ player.settings.setRunToggled(true)
+ }
+
+ private fun disableRun(player: Player) {
+ player.settings.runEnergy = 100.0
+ player.settings.setRunToggled(false)
+ player.walkingQueue.setRunning(false)
+ }
+
+ private fun stationaryNpc(id: Int, location: Location): NPC {
+ val npc = NPC.create(id, location)
+ npc.isWalks = false
+ npc.isNeverWalks = true
+ npc.init()
+ npc.skills.setStaticLevel(Skills.HITPOINTS, 10_000)
+ npc.skills.lifepoints = 10_000
+ return npc
+ }
+
+ private fun walkableTileNear(target: Location, minDistance: Int, maxRadius: Int): Location {
+ for (radius in minDistance..maxRadius) {
+ for (dx in -radius..radius) {
+ for (dy in -radius..radius) {
+ if (abs(dx) != radius && abs(dy) != radius) {
+ continue
+ }
+ val candidate = target.transform(dx, dy, 0)
+ if (
+ candidate.getDistance(target) >= minDistance &&
+ RegionManager.isTeleportPermitted(candidate)
+ ) {
+ return candidate
+ }
+ }
+ }
+ }
+ throw AssertionError(
+ "No walkable tile found near $target between $minDistance and $maxRadius tiles."
+ )
+ }
+
+ private fun rangedApproachCandidates(
+ attacker: Entity,
+ target: Entity,
+ range: Int,
+ ): List {
+ val targetLocation = target.location
+ val rangeSquared = range * range
+ val candidates = ArrayList()
+ for (x in targetLocation.x - range..targetLocation.x + target.size() - 1 + range) {
+ for (y in targetLocation.y - range..targetLocation.y + target.size() - 1 + range) {
+ val tile = Location.create(x, y, targetLocation.z)
+ val closestX = x.coerceIn(targetLocation.x, targetLocation.x + target.size() - 1)
+ val closestY = y.coerceIn(targetLocation.y, targetLocation.y + target.size() - 1)
+ val targetDx = x - closestX
+ val targetDy = y - closestY
+ if (
+ targetDx * targetDx + targetDy * targetDy <= rangeSquared &&
+ RegionManager.isTeleportPermitted(tile)
+ ) {
+ candidates.add(tile)
+ }
+ }
+ }
+ return candidates
+ .sortedWith(
+ compareBy {
+ val dx = it.x - attacker.location.x
+ val dy = it.y - attacker.location.y
+ dx * dx + dy * dy
+ }
+ .thenBy {
+ val closestX =
+ it.x.coerceIn(targetLocation.x, targetLocation.x + target.size() - 1)
+ val closestY =
+ it.y.coerceIn(targetLocation.y, targetLocation.y + target.size() - 1)
+ val dx = it.x - closestX
+ val dy = it.y - closestY
+ dx * dx + dy * dy
+ }
+ .thenBy { it.x }
+ .thenBy { it.y }
+ )
+ .filter {
+ RsmodPathfinder.hasLineOfSightBetween(
+ it,
+ attacker.size(),
+ targetLocation,
+ target.size(),
+ )
+ }
+ }
+
+ private fun perimeterEdges(tiles: Set): List> {
+ val edges = ArrayList>()
+ for (tile in tiles) {
+ for (neighbour in
+ listOf(
+ tile.transform(0, 1, 0),
+ tile.transform(1, 0, 0),
+ tile.transform(0, -1, 0),
+ tile.transform(-1, 0, 0),
+ )) {
+ if (neighbour !in tiles) {
+ edges.add(tile to neighbour)
+ }
+ }
+ }
+ return edges
+ }
+
+ private fun enablePvp(first: Entity, second: Entity) {
+ core.game.world.GameWorld.settings!!.wild_pvp_enabled = true
+ first.asPlayer().skullManager.isWilderness = true
+ first.asPlayer().skullManager.level = 50
+ second.asPlayer().skullManager.isWilderness = true
+ second.asPlayer().skullManager.level = 50
+ }
+
+ private fun meleeReach(attacker: Entity, victim: Entity): Boolean {
+ return CombatReach.canMelee(attacker, victim, CombatReach.meleeDistance(attacker))
+ }
+
+ private fun magicReach(attacker: Entity, victim: Entity): Boolean {
+ return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <=
+ 10.0 &&
+ CombatSwingHandler.isProjectileClipped(
+ attacker,
+ victim,
+ false,
+ )
+ }
+
+ private object TestAutocastSpell : CombatSpell() {
+ init {
+ spellId = 1
+ }
+
+ override fun getMaximumImpact(entity: Entity, victim: Entity, state: BattleState): Int {
+ return 1
+ }
+
+ override fun visualize(entity: Entity, target: Node?) {}
+
+ override fun visualizeImpact(entity: Entity?, target: Entity?, state: BattleState?) {}
+
+ override fun newInstance(arg: SpellType?): Plugin {
+ return this
+ }
+ }
+
+ /**
+ * Adds or removes a fence on the shared edge of two cardinally adjacent tiles: movement
+ * wall flags mirrored onto both tiles (like real map walls), no projectile flags.
+ */
+ private fun setFence(first: Location, second: Location, add: Boolean) {
+ val flags =
+ when (second) {
+ first.transform(0, 1, 0) -> CollisionFlag.WALL_NORTH to CollisionFlag.WALL_SOUTH
+ first.transform(0, -1, 0) -> CollisionFlag.WALL_SOUTH to CollisionFlag.WALL_NORTH
+ first.transform(1, 0, 0) -> CollisionFlag.WALL_EAST to CollisionFlag.WALL_WEST
+ first.transform(-1, 0, 0) -> CollisionFlag.WALL_WEST to CollisionFlag.WALL_EAST
+ else ->
+ throw IllegalArgumentException(
+ "Fence tiles must be cardinally adjacent: $first, $second"
+ )
+ }
+ if (add) {
+ RegionManager.addClippingFlag(first.z, first.x, first.y, false, flags.first)
+ RegionManager.addClippingFlag(second.z, second.x, second.y, false, flags.second)
+ } else {
+ RegionManager.removeClippingFlag(first.z, first.x, first.y, false, flags.first)
+ RegionManager.removeClippingFlag(second.z, second.x, second.y, false, flags.second)
+ }
+ }
+
+ private fun blockMovementTiles(tiles: List) {
+ for (tile in tiles) {
+ RegionManager.addClippingFlag(tile.z, tile.x, tile.y, false, movementBlockFlag)
+ }
+ }
+
+ private fun unblockMovementTiles(tiles: List) {
+ for (tile in tiles) {
+ RegionManager.removeClippingFlag(tile.z, tile.x, tile.y, false, movementBlockFlag)
+ }
+ }
+
+ private fun receivedMessage(player: Player, message: String): Boolean {
+ return (player.session as MockSession).receivedPackets.any { packet ->
+ val end = packet.payload.indexOf(0.toByte())
+ end == message.length && String(packet.payload, 0, end, Charsets.UTF_8) == message
+ }
+ }
+
+ private val movementBlockFlag =
+ Pathfinder.PREVENT_NORTH or
+ Pathfinder.PREVENT_EAST or
+ Pathfinder.PREVENT_SOUTH or
+ Pathfinder.PREVENT_WEST
+}
diff --git a/Server/src/test/kotlin/content/CombatPerformanceTests.kt b/Server/src/test/kotlin/content/CombatPerformanceTests.kt
new file mode 100644
index 000000000..5b81032a6
--- /dev/null
+++ b/Server/src/test/kotlin/content/CombatPerformanceTests.kt
@@ -0,0 +1,209 @@
+package content
+
+import TestUtils
+import core.ServerConstants
+import core.game.node.entity.combat.CombatMovementIntents
+import core.game.node.entity.combat.equipment.WeaponInterface
+import core.game.node.entity.player.Player
+import core.game.node.entity.skill.Skills
+import core.game.world.GameWorld
+import core.game.world.map.Location
+import core.game.world.repository.Repository
+import core.game.world.update.UpdateSequence
+import core.net.packet.PacketProcessor
+import core.tools.LogLevel
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Disabled
+import org.junit.jupiter.api.Test
+import java.util.concurrent.TimeUnit
+
+@Disabled // ENABLE LOCALLY ONLY
+class CombatPerformanceTests {
+ init {
+ TestUtils.preTestSetup()
+ }
+
+ @Test
+ fun serverTickStaysWithinBudgetWithLiveSizedCombatLoad() {
+ withQuietPerformanceLogs {
+ var load: CombatLoad? = null
+ try {
+ load = createCombatLoad()
+ assertEquals(REAL_PLAYER_COUNT, load.players.count { !it.isArtificial })
+ assertEquals(BOT_PLAYER_COUNT, load.players.count { it.isArtificial })
+
+ repeat(WARMUP_TICKS) {
+ measureCombatTick(load, it)
+ }
+
+ val durations =
+ LongArray(MEASURED_TICKS) { tick ->
+ measureCombatTick(load, tick + WARMUP_TICKS)
+ }
+ val sorted = durations.sorted()
+ val p90Index = ((sorted.size * 9 + 9) / 10 - 1).coerceIn(0, sorted.lastIndex)
+ val p90 = sorted[p90Index]
+ val max = sorted.last()
+ val durationText = durations.joinToString(prefix = "[", postfix = "]")
+
+ assertTrue(
+ p90 <= HEADROOM_TICK_BUDGET_MILLIS,
+ "650-player combat p90 tick time should leave room for slower live hardware. " +
+ "durations=${durationText}ms, p90=${p90}ms, " +
+ "budget=${HEADROOM_TICK_BUDGET_MILLIS}ms",
+ )
+ assertTrue(
+ max <= LIVE_TICK_BUDGET_MILLIS,
+ "650-player combat tick should remain under the live 600ms server tick budget. " +
+ "durations=${durationText}ms, max=${max}ms",
+ )
+ } finally {
+ load?.close()
+ }
+ }
+ }
+
+ private fun withQuietPerformanceLogs(action: () -> T): T {
+ val previousLogLevel = ServerConstants.LOG_LEVEL
+ ServerConstants.LOG_LEVEL = LogLevel.CAUTIOUS
+ try {
+ return action()
+ } finally {
+ ServerConstants.LOG_LEVEL = previousLogLevel
+ }
+ }
+
+ private fun createCombatLoad(): CombatLoad {
+ val players = ArrayList(TOTAL_PLAYER_COUNT)
+ val previousWildPvp = GameWorld.settings!!.wild_pvp_enabled
+
+ for (i in 0 until TOTAL_PLAYER_COUNT) {
+ val player = TestUtils.getMockPlayer("combat_perf_$i", isBot = i >= REAL_PLAYER_COUNT)
+ players.add(player)
+ configureMeleePlayer(player)
+ }
+
+ val pairs =
+ players.chunked(2).mapIndexed { index, pair ->
+ CombatPair(pair[0], pair[1], pairOrigin(index))
+ }
+ val load = CombatLoad(players, pairs, previousWildPvp)
+
+ GameWorld.settings!!.wild_pvp_enabled = true
+ load.resetPairPositionsAndMovement(0)
+ for (pair in pairs) {
+ pair.first.attack(pair.second)
+ pair.second.attack(pair.first)
+ }
+ return load
+ }
+
+ private fun measureCombatTick(load: CombatLoad, tick: Int): Long {
+ PacketProcessor.clearQueue()
+ load.resetPairPositionsAndMovement(tick)
+ load.requestAllCombatMovement()
+
+ assertEquals(
+ TOTAL_PLAYER_COUNT,
+ CombatMovementIntents.pendingCount(),
+ "The performance fixture should exercise one combat movement intent per loaded player.",
+ )
+
+ val start = System.nanoTime()
+ GameWorld.majorUpdateWorker.handleTickActions(false)
+ return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)
+ }
+
+ private fun configureMeleePlayer(player: Player) {
+ player.properties.attackStyle =
+ WeaponInterface.AttackStyle(
+ WeaponInterface.STYLE_AGGRESSIVE,
+ WeaponInterface.BONUS_CRUSH,
+ )
+ player.properties.combatPulse.updateStyle()
+ player.properties.combatLevel = 126
+ player.skills.setStaticLevel(Skills.HITPOINTS, 10_000)
+ player.skills.lifepoints = 10_000
+ player.settings.runEnergy = 100.0
+ player.settings.setRunToggled(true)
+ player.skullManager.isWilderness = true
+ player.skullManager.level = 50
+ }
+
+ private data class CombatPair(
+ val first: Player,
+ val second: Player,
+ val origin: Location,
+ )
+
+ private class CombatLoad(
+ val players: List,
+ private val pairs: List,
+ private val previousWildPvp: Boolean,
+ ) : AutoCloseable {
+
+ fun resetPairPositionsAndMovement(tick: Int) {
+ val direction = if (tick % 2 == 0) 1 else -1
+ for (pair in pairs) {
+ val firstLocation = pair.origin
+ val secondLocation = pair.origin.transform(1, 0, 0)
+ place(pair.first, firstLocation)
+ place(pair.second, secondLocation)
+ queueRun(pair.first, firstLocation.transform(-8 * direction, 0, 0))
+ queueRun(pair.second, secondLocation.transform(8 * direction, 0, 0))
+ }
+ }
+
+ fun requestAllCombatMovement() {
+ CombatMovementIntents.clear()
+ for (pair in pairs) {
+ CombatMovementIntents.request(pair.first, pair.second)
+ CombatMovementIntents.request(pair.second, pair.first)
+ }
+ }
+
+ override fun close() {
+ CombatMovementIntents.clear()
+ for (player in players.asReversed()) {
+ player.pulseManager.clear()
+ player.walkingQueue.reset()
+ player.isActive = false
+ player.setPlaying(false)
+ Repository.removePlayer(player)
+ UpdateSequence.renderablePlayers.remove(player)
+ }
+ GameWorld.Pulser.updateAll()
+ UpdateSequence.renderablePlayers.sync()
+ PacketProcessor.clearQueue()
+ GameWorld.settings!!.wild_pvp_enabled = previousWildPvp
+ }
+
+ private fun place(player: Player, location: Location) {
+ player.location = location
+ player.walkingQueue.reset()
+ }
+
+ private fun queueRun(player: Player, destination: Location) {
+ player.walkingQueue.reset(true)
+ player.walkingQueue.addPath(destination.x, destination.y)
+ }
+ }
+
+ private companion object {
+ const val REAL_PLAYER_COUNT = 150
+ const val BOT_PLAYER_COUNT = 500
+ const val TOTAL_PLAYER_COUNT = REAL_PLAYER_COUNT + BOT_PLAYER_COUNT
+ const val WARMUP_TICKS = 3
+ const val MEASURED_TICKS = 10
+ const val LIVE_TICK_BUDGET_MILLIS = 600L
+ const val HEADROOM_TICK_BUDGET_MILLIS = 350L
+
+ fun pairOrigin(index: Int): Location {
+ val columns = 25
+ val column = index % columns
+ val row = index / columns
+ return Location.create(3200 + column * 12, 3600 + row * 6, 0)
+ }
+ }
+}
diff --git a/Server/src/test/kotlin/content/CombatTests.kt b/Server/src/test/kotlin/content/CombatTests.kt
index 762ed8406..1eec4be78 100644
--- a/Server/src/test/kotlin/content/CombatTests.kt
+++ b/Server/src/test/kotlin/content/CombatTests.kt
@@ -2,15 +2,20 @@ package content
import TestUtils
import content.global.handlers.item.equipment.special.ChinchompaSwingHandler
+import core.ServerConstants
import core.api.EquipmentSlot
import core.game.container.impl.EquipmentContainer.updateBonuses
import core.game.interaction.IntType
import core.game.interaction.InteractionListeners
+import core.game.node.entity.combat.CombatMovementIntents
+import core.game.node.entity.combat.CombatMovementPlanner
+import core.game.node.entity.combat.CombatReach
import core.game.node.entity.combat.MagicSwingHandler
import core.game.node.entity.combat.MeleeSwingHandler
import core.game.node.entity.combat.RangeSwingHandler
import core.game.node.entity.combat.SwingHandlerFlag
import core.game.node.entity.combat.equipment.WeaponInterface
+import core.game.node.entity.npc.NPC
import core.game.node.entity.player.link.prayer.PrayerType
import core.game.node.entity.skill.Skills
import core.game.node.item.Item
@@ -135,4 +140,89 @@ class CombatTests {
Assertions.assertEquals(damageBaseline, handler.calculateHit(p, p, 1.0))
}
}
-}
\ No newline at end of file
+
+ @Test
+ fun combatReachUsesOccupiedTilesForLargeMeleeTargets() {
+ TestUtils.getMockPlayer("combatReachLargeTarget").use { attacker ->
+ val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
+ val victim = NPC.create(100, origin.transform(1, 0, 0))
+ victim.setSize(2)
+ attacker.location = origin
+
+ Assertions.assertTrue(CombatReach.canMelee(attacker, victim, 1))
+ Assertions.assertTrue(MeleeSwingHandler.canMelee(attacker, victim, 1))
+ }
+ }
+
+ @Test
+ fun combatMovementPlannerPredictsRunningTargetSteps() {
+ TestUtils.getMockPlayer("combatPlannerRunner").use { target ->
+ val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
+ target.location = origin
+ target.walkingQueue.reset(true)
+ target.walkingQueue.addPath(origin.x + 3, origin.y)
+
+ Assertions.assertEquals(
+ listOf(origin.transform(1, 0, 0), origin.transform(2, 0, 0)),
+ CombatMovementPlanner.predictTargetLocations(target)
+ )
+ Assertions.assertEquals(origin.transform(2, 0, 0), CombatMovementPlanner.predictedTargetLocation(target))
+ }
+ }
+
+ @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 ->
+ val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
+ val victim = NPC.create(100, origin.transform(4, 0, 0))
+ victim.setSize(2)
+ attacker.location = origin
+
+ Assertions.assertEquals(
+ origin.transform(3, 0, 0),
+ CombatMovementPlanner.chooseTargetBorderTile(attacker, victim, victim.location)
+ )
+ }
+ }
+
+ @Test
+ fun combatMovementIntentResolverAppliesPendingMeleePathBeforeEntityMovement() {
+ TestUtils.getMockPlayer("combatIntentAttacker").use { attacker ->
+ TestUtils.getMockPlayer("combatIntentVictim").use { victim ->
+ val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
+ attacker.location = origin
+ victim.location = origin.transform(4, 0, 0)
+ attacker.properties.attackStyle = WeaponInterface.AttackStyle(
+ WeaponInterface.STYLE_AGGRESSIVE,
+ WeaponInterface.BONUS_CRUSH
+ )
+ attacker.properties.combatPulse.updateStyle()
+ attacker.attack(victim)
+
+ CombatMovementIntents.request(attacker, victim)
+ CombatMovementIntents.resolve()
+
+ Assertions.assertTrue(attacker.walkingQueue.hasPath())
+ Assertions.assertEquals(0, CombatMovementIntents.pendingCount())
+ }
+ }
+ }
+}
diff --git a/Server/src/test/kotlin/content/StallGuardReactionTests.kt b/Server/src/test/kotlin/content/StallGuardReactionTests.kt
new file mode 100644
index 000000000..7fe53ee03
--- /dev/null
+++ b/Server/src/test/kotlin/content/StallGuardReactionTests.kt
@@ -0,0 +1,266 @@
+package content
+
+import MockSession
+import TestUtils
+import content.global.skill.thieving.StallThiefPulse
+import content.global.skill.thieving.ThievingOptionPlugin
+import core.game.node.entity.Entity
+import core.game.node.entity.combat.CombatMovementIntents
+import core.game.node.entity.npc.NPC
+import core.game.node.entity.player.Player
+import core.game.node.entity.skill.Skills
+import core.game.world.map.Location
+import core.game.world.map.RegionManager
+import core.game.world.map.path.Pathfinder
+import org.junit.jupiter.api.Assertions.*
+import org.junit.jupiter.api.Test
+
+/**
+ * Regression tests for the Ardougne market stall mechanic: a failed steal must never
+ * fail silently. Any guard in range busts the steal; a guard whose attack can actually
+ * connect is preferred over one boxed in behind a stall or inside the guardhouse, so
+ * the shout is followed by a real attack whenever possible.
+ */
+class StallGuardReactionTests {
+ init {
+ TestUtils.preTestSetup()
+ }
+
+ private fun findBakersStall(): core.game.node.scenery.Scenery {
+ core.game.world.map.Region.load(RegionManager.forId(10547))
+ val bakerIds = setOf(2561, 6163, 34384)
+ for (x in 2650..2680) {
+ for (y in 3295..3325) {
+ val obj = RegionManager.getObject(0, x, y) ?: continue
+ if (obj.id in bakerIds) {
+ return obj
+ }
+ }
+ }
+ throw AssertionError("No baker's stall found in the Ardougne market square.")
+ }
+
+ private fun adjacentWalkableTile(stall: core.game.node.scenery.Scenery): Location {
+ val base = stall.location
+ val candidates = ArrayList()
+ for (dx in -1..2) {
+ for (dy in -1..2) {
+ if (dx in 0..1 && dy in 0..1) continue
+ candidates.add(base.transform(dx, dy, 0))
+ }
+ }
+ return candidates.firstOrNull { RegionManager.isTeleportPermitted(it) }
+ ?: throw AssertionError("No walkable tile adjacent to the stall at $base.")
+ }
+
+ private fun place(entity: Entity, location: Location) {
+ entity.location = location
+ RegionManager.move(entity)
+ entity.walkingQueue.reset()
+ }
+
+ private fun assertGuardAttacks(guard: NPC, player: Player, scenario: String) {
+ TestUtils.advanceTicks(30, false)
+ assertTrue(
+ player.inCombat(),
+ "[$scenario] Player should have been hit by the guard. " +
+ "guard=${guard.location}, attacking=${guard.properties.combatPulse.isAttacking}, " +
+ "player=${player.location}, ${CombatMovementIntents.lastResolveSummary()}",
+ )
+ }
+
+ private val movementBlockFlag =
+ Pathfinder.PREVENT_NORTH or
+ Pathfinder.PREVENT_EAST or
+ Pathfinder.PREVENT_SOUTH or
+ Pathfinder.PREVENT_WEST
+
+ /** Finds an origin whose east and north arms (4 tiles each) are fully walkable. */
+ private fun openCrossOrigin(): Location {
+ val start = Location.create(3200, 3600, 0)
+ for (dy in -16..16) {
+ for (dx in -16..16) {
+ val candidate = start.transform(dx, dy, 0)
+ val armsOpen = (0..4).all {
+ RegionManager.isTeleportPermitted(candidate.transform(it, 0, 0)) &&
+ RegionManager.isTeleportPermitted(candidate.transform(0, it, 0))
+ }
+ if (armsOpen) {
+ return candidate
+ }
+ }
+ }
+ throw AssertionError("No open cross-shaped area found near $start.")
+ }
+
+ @Test
+ fun blockedGuardStillBustsTheStealButReachableGuardIsPreferred() {
+ TestUtils.getMockPlayer("stall_guard_selection").use { player ->
+ val origin = openCrossOrigin()
+ place(player, origin)
+ // Wall column two tiles east of the player, between them and the guard.
+ val wall = (-2..2).map { origin.transform(2, it, 0) }
+ val blockedGuard = NPC.create(32, origin.transform(4, 0, 0))
+ try {
+ wall.forEach { RegionManager.addClippingFlag(it.z, it.x, it.y, false, movementBlockFlag) }
+ blockedGuard.init()
+ place(blockedGuard, origin.transform(4, 0, 0))
+
+ assertSame(
+ blockedGuard,
+ StallThiefPulse.findGuardForFailedSteal(player),
+ "A guard whose chase route is blocked must still bust the steal.",
+ )
+
+ val reachableGuard = NPC.create(32, origin.transform(0, 4, 0))
+ try {
+ reachableGuard.init()
+ place(reachableGuard, origin.transform(0, 4, 0))
+ assertSame(
+ reachableGuard,
+ StallThiefPulse.findGuardForFailedSteal(player),
+ "The reachable guard should be picked over the blocked one.",
+ )
+ } finally {
+ reachableGuard.clear()
+ }
+ } finally {
+ wall.forEach { RegionManager.removeClippingFlag(it.z, it.x, it.y, false, movementBlockFlag) }
+ blockedGuard.clear()
+ }
+ }
+ }
+
+ @Test
+ fun guardBusyFightingSomeoneElseDoesNotBustTheSteal() {
+ TestUtils.getMockPlayer("stall_guard_busy").use { player ->
+ TestUtils.getMockPlayer("stall_guard_other_victim").use { other ->
+ val origin = openCrossOrigin()
+ place(player, origin)
+ place(other, origin.transform(4, 1, 0))
+ val guard = NPC.create(32, origin.transform(4, 0, 0))
+ try {
+ guard.init()
+ place(guard, origin.transform(4, 0, 0))
+ guard.properties.combatPulse.attack(other)
+ assertNull(
+ StallThiefPulse.findGuardForFailedSteal(player),
+ "A guard busy fighting someone else should not notice the thief.",
+ )
+ guard.properties.combatPulse.stop()
+ assertSame(
+ guard,
+ StallThiefPulse.findGuardForFailedSteal(player),
+ "Once free, the same guard should bust the thief again.",
+ )
+ } finally {
+ guard.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+ }
+
+ @Test
+ fun ardougneGuardShouldAttackPlayerCaughtStealing() {
+ TestUtils.getMockPlayer("stall_thief").use { player ->
+ val stall = findBakersStall()
+ place(player, adjacentWalkableTile(stall))
+ player.skills.setStaticLevel(Skills.HITPOINTS, 99)
+ player.skills.lifepoints = 5000
+ player.properties.isRetaliating = false
+ val guardSpawn = Location.create(2661, 3309, 0)
+ val guard = NPC.create(32, guardSpawn)
+ guard.init()
+ try {
+ place(guard, guardSpawn)
+ val caughtBy = StallThiefPulse.findGuardForFailedSteal(player)
+ assertNotNull(caughtBy, "A guard should be found near the stall.")
+ caughtBy!!.sendChat("Hey! Get your hands off there!")
+ caughtBy.properties.combatPulse.attack(player)
+ assertGuardAttacks(caughtBy, player, "open-path stall=${stall.location}")
+ } finally {
+ guard.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ @Test
+ fun walkingGuardShouldStillAttackWhenCaught() {
+ TestUtils.getMockPlayer("stall_thief_walker").use { player ->
+ val stall = findBakersStall()
+ place(player, adjacentWalkableTile(stall))
+ player.skills.setStaticLevel(Skills.HITPOINTS, 99)
+ player.skills.lifepoints = 5000
+ player.properties.isRetaliating = false
+ val guardSpawn = Location.create(2661, 3309, 0)
+ val guard = NPC.create(32, guardSpawn)
+ guard.init()
+ try {
+ // Guard is mid-wander when the player gets caught.
+ guard.walkingQueue.reset(false)
+ guard.walkingQueue.addPath(guardSpawn.x - 3, guardSpawn.y)
+ TestUtils.advanceTicks(1, false)
+ guard.sendChat("Hey! Get your hands off there!")
+ guard.properties.combatPulse.attack(player)
+ assertGuardAttacks(guard, player, "walking-guard stall=${stall.location}")
+ } finally {
+ guard.clear()
+ CombatMovementIntents.clear()
+ }
+ }
+ }
+
+ /**
+ * End-to-end: clicking Steal-from must start the thieving attempt from every
+ * walkable tile around the stall. A tile from which the click silently does
+ * nothing (no attempt, no message) reproduces the reported bug.
+ */
+ @Test
+ fun stealFromClickShouldStartAttemptFromEveryNearbyTile() {
+ ThievingOptionPlugin().newInstance(null)
+ TestUtils.getMockPlayer("stall_click_thief").use { player ->
+ val stall = findBakersStall()
+ player.skills.setStaticLevel(Skills.THIEVING, 99)
+ player.skills.setLevel(Skills.THIEVING, 99)
+ player.skills.setStaticLevel(Skills.HITPOINTS, 99)
+ player.properties.isRetaliating = false
+ val optIndex = stall.interaction.options.indexOfFirst {
+ it != null && it.name.equals("steal-from", ignoreCase = true)
+ }
+ assertTrue(optIndex >= 0, "Stall has no Steal-from option: ${stall.interaction.options?.map { it?.name }}")
+
+ val base = stall.location
+ val silent = ArrayList()
+ var attempts = 0
+ for (dx in -2..3) {
+ for (dy in -2..3) {
+ if (dx in 0..1 && dy in 0..1) continue // stall footprint
+ val tile = base.transform(dx, dy, 0)
+ if (!RegionManager.isTeleportPermitted(tile)) continue
+ place(player, tile)
+ player.removeAttribute("thieveDelay")
+ player.removeAttribute("combat-time")
+ player.properties.combatPulse.stop()
+ player.inventory.clear()
+ player.skills.lifepoints = 5000
+ attempts++
+ val session = player.session as MockSession
+ session.receivedPackets.clear()
+ TestUtils.simulateInteraction(player, stall, optIndex)
+ TestUtils.advanceTicks(10, false)
+ if (player.getAttribute("thieveDelay", null) == null) {
+ silent.add(tile)
+ }
+ TestUtils.advanceTicks(10, false) // let locks/stall respawn settle
+ }
+ }
+ assertTrue(attempts > 0, "No walkable tiles found around the stall.")
+ assertTrue(
+ silent.isEmpty(),
+ "Steal-from click did nothing from ${silent.size}/$attempts tiles: $silent (stall=$base)",
+ )
+ }
+ }
+}
diff --git a/Server/src/test/kotlin/core/PathfinderTests.kt b/Server/src/test/kotlin/core/PathfinderTests.kt
index 84f6c640c..081551db1 100644
--- a/Server/src/test/kotlin/core/PathfinderTests.kt
+++ b/Server/src/test/kotlin/core/PathfinderTests.kt
@@ -1,53 +1,653 @@
package core
import TestUtils
+import content.global.handlers.npc.NPCTalkListener
+import content.global.handlers.scenery.BankBoothListener
import content.global.skill.gather.GatheringSkillOptionListeners
import content.global.skill.gather.woodcutting.WoodcuttingListener
+import content.region.misthalin.varrock.dialogue.GrandExchangeClerk
+import content.region.misthalin.varrock.handlers.GrandExchangePlugin
import core.api.log
import core.cache.def.impl.NPCDefinition
+import core.game.dialogue.DialogueInterpreter
import core.game.interaction.*
-import core.game.node.scenery.Scenery
-import core.game.world.map.Location
-import core.game.world.map.RegionManager
-import org.junit.jupiter.api.Assertions
-import org.junit.jupiter.api.Test
import core.game.node.Node
import core.game.node.entity.impl.PulseType
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
+import core.game.node.scenery.Scenery
import core.game.world.GameWorld
-import core.game.world.map.Region
-import core.net.packet.PacketProcessor
-import core.plugin.ClassScanner
+import core.game.world.map.Direction
+import core.game.world.map.Location
+import core.game.world.map.RegionManager
+import core.game.world.map.path.ClipMaskSupplier
+import core.game.world.map.path.Pathfinder
+import core.game.world.map.path.RsmodPathfinder
import core.plugin.Plugin
import core.tools.Log
-import org.rs09.consts.NPCs
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.Test
+import org.rs09.consts.Scenery as SceneryIds
class PathfinderTests {
- companion object {init {TestUtils.preTestSetup(); GatheringSkillOptionListeners().defineListeners(); WoodcuttingListener().defineListeners() }; val NPC_TEST_LOC = ServerConstants.HOME_LOCATION!!.transform(2, 10, 0)}
+ companion object {
+ init {
+ TestUtils.preTestSetup()
+ GatheringSkillOptionListeners().defineListeners()
+ WoodcuttingListener().defineListeners()
+ BankBoothListener().defineListeners()
+ }
- @Test fun getOccupiedTilesShouldReturnCorrectSetOfTilesThatAnObjectOccupiesAtAllRotations() {
+ val NPC_TEST_LOC = ServerConstants.HOME_LOCATION!!.transform(2, 10, 0)
+ }
+
+ @Test
+ fun rsmodPathfinderShouldRejectDestinationsAtTheTruncationLimit() {
+ val start = Location.create(3165, 3218, 0)
+
+ Assertions.assertTrue(RsmodPathfinder.canAttempt(start, Location.create(3203, 3186, 0)))
+ Assertions.assertFalse(RsmodPathfinder.canAttempt(start, Location.create(3203, 3185, 0)))
+ }
+
+ @Test
+ fun getOccupiedTilesShouldReturnCorrectSetOfTilesThatAnObjectOccupiesAtAllRotations() {
//clay fireplace - 13609 - sizex: 1, sizey: 2
val scenery = Scenery(13609, Location.create(50, 50, 0))
scenery.rotation = 0
val occupiedAt0 = scenery.occupiedTiles.toTypedArray()
- Assertions.assertArrayEquals(arrayOf(Location.create(50, 50), Location.create(50,51)), occupiedAt0)
+ Assertions.assertArrayEquals(arrayOf(Location.create(50, 50), Location.create(50, 51)), occupiedAt0)
scenery.rotation = 1
val occupiedAt1 = scenery.occupiedTiles.toTypedArray()
- Assertions.assertArrayEquals(arrayOf(Location.create(50,50), Location.create(51,50)), occupiedAt1)
+ Assertions.assertArrayEquals(arrayOf(Location.create(50, 50), Location.create(51, 50)), occupiedAt1)
scenery.rotation = 2
val occupiedAt2 = scenery.occupiedTiles.toTypedArray()
- Assertions.assertArrayEquals(arrayOf(Location.create(50,50), Location.create(50,49)), occupiedAt2)
+ Assertions.assertArrayEquals(arrayOf(Location.create(50, 50), Location.create(50, 49)), occupiedAt2)
scenery.rotation = 3
val occupiedAt3 = scenery.occupiedTiles.toTypedArray()
- Assertions.assertArrayEquals(arrayOf(Location.create(50,50), Location.create(49,50)), occupiedAt3)
+ Assertions.assertArrayEquals(arrayOf(Location.create(50, 50), Location.create(49, 50)), occupiedAt3)
}
- @Test fun movementPulseShouldStopEarlyIfNextToATileOccupiedByTargetObject() {
+ @Test
+ fun smartPathfinderShouldRespectSuppliedClipMask() {
+ val start = Location.create(3200, 3200, 0)
+ val dest = Location.create(3202, 3200, 0)
+ val blockedDestination = ClipMaskSupplier { _, x, y ->
+ if (x == dest.x && y == dest.y) 0x100 else 0
+ }
+
+ val path = Pathfinder.SMART.find(start, 1, dest, 1, 1, 0, 0, 0, true, blockedDestination)
+
+ Assertions.assertTrue(path.isSuccessful)
+ Assertions.assertEquals(
+ Location.create(3201, 3200, 0), Location.create(path.points.last.x, path.points.last.y, 0)
+ )
+ }
+
+ @Test
+ fun dumbPathfinderShouldNotRouteAroundBlockedCardinalStep() {
+ val start = Location.create(3200, 3200, 0)
+ val dest = Location.create(3202, 3200, 0)
+ val blockedMiddle = ClipMaskSupplier { _, x, y ->
+ if (x == 3201 && y == 3200) movementBlockFlag else 0
+ }
+
+ val path = Pathfinder.DUMB.find(start, 1, dest, 0, 0, 0, -1, 0, false, blockedMiddle)
+
+ Assertions.assertFalse(path.isSuccessful)
+ Assertions.assertTrue(path.points.isEmpty())
+ }
+
+ @Test
+ fun dumbPathfinderShouldUseAxisFallbackForBlockedDiagonalStep() {
+ val start = Location.create(3200, 3200, 0)
+ val dest = Location.create(3202, 3202, 0)
+ val blockedNorth = ClipMaskSupplier { _, x, y ->
+ if (x == 3200 && y == 3201) movementBlockFlag else 0
+ }
+
+ val path = Pathfinder.DUMB.find(start, 1, dest, 0, 0, 0, -1, 0, false, blockedNorth)
+
+ Assertions.assertTrue(path.isSuccessful)
+ Assertions.assertTrue(path.points.isNotEmpty())
+ Assertions.assertEquals(3201, path.points.first.x)
+ Assertions.assertEquals(3200, path.points.first.y)
+ }
+
+ @Test
+ fun walkingQueueHasPathShouldIgnoreResetAnchor() {
+ TestUtils.getMockPlayer("walkingQueueAnchor").use { player ->
+ val start = Location.create(3200, 3200, 0)
+ player.location = start
+ player.walkingQueue.reset()
+
+ Assertions.assertFalse(player.walkingQueue.hasPath())
+
+ player.walkingQueue.addPath(start.x + 1, start.y)
+
+ Assertions.assertTrue(player.walkingQueue.hasPath())
+ }
+ }
+
+ @Test
+ fun projectilePathfinderShouldUseRsmodLineOfSightFlags() {
+ val start = Location.create(3200, 3200, 0)
+ val dest = Location.create(3202, 3200, 0)
+ RegionManager.loadClippingWindow(start, 128)
+ try {
+ RegionManager.setRsmodFlag(0, 3201, 3200, true, 0x20000)
+
+ val blocked = Pathfinder.PROJECTILE.find(start, 1, dest, 0, 0, 0, -1, 0, false, null)
+
+ Assertions.assertFalse(blocked.isSuccessful)
+ Assertions.assertFalse(RsmodPathfinder.hasLineOfSightBetween(start, 1, dest, 1))
+ RsmodPathfinder.loadLineOfSightWindow(start)
+ Assertions.assertFalse(RsmodPathfinder.hasLineOfSightBetweenLoaded(start, 1, dest, 1))
+ } finally {
+ RegionManager.setRsmodFlag(0, 3201, 3200, true, 0)
+ }
+ }
+
+ @Test
+ fun projectilePathfinderShouldTreatZeroSizedLocationDestinationsAsOneTile() {
+ val start = Location.create(3204, 3204, 0)
+ val destinations =
+ listOf(
+ Location.create(3202, 3204, 0),
+ Location.create(3204, 3202, 0),
+ )
+ val fixtureTiles =
+ (3201..3204).flatMap { x ->
+ (3201..3204).map { y -> Location.create(x, y, 0) }
+ }
+ RegionManager.loadClippingWindow(start, 128)
+ val originalFlags = fixtureTiles.associateWith {
+ RegionManager.getProjectileFlag(it.z, it.x, it.y)
+ }
+ try {
+ for (tile in fixtureTiles) {
+ RegionManager.setRsmodFlag(tile.z, tile.x, tile.y, true, 0)
+ }
+
+ for (destination in destinations) {
+ val path =
+ Pathfinder.PROJECTILE.find(
+ start,
+ 1,
+ destination,
+ 0,
+ 0,
+ 0,
+ -1,
+ 0,
+ false,
+ null,
+ )
+
+ Assertions.assertTrue(path.isSuccessful)
+ Assertions.assertEquals(
+ destination,
+ Location.create(path.points.last.x, path.points.last.y, start.z),
+ "A west/south projectile ray must stop on its zero-sized Location destination.",
+ )
+ }
+ } finally {
+ for ((tile, flag) in originalFlags) {
+ RegionManager.setRsmodFlag(tile.z, tile.x, tile.y, true, flag)
+ }
+ }
+ }
+
+ @Test
+ fun metadataSceneryInteractionShouldTriggerWhenAlreadyAtRsmodApproachTile() {
+ TestUtils.getMockPlayer("bankBoothApproach").use { p ->
+ val (booth, approach) = findReachableBankBoothFixture()
+ p.location = approach
+ val alreadyAtPath = Pathfinder.find(p, booth)
+ Assertions.assertTrue(alreadyAtPath.isSuccessful)
+ Assertions.assertFalse(alreadyAtPath.isMoveNear)
+
+ Assertions.assertTrue(InteractionListeners.run(booth.id, IntType.SCENERY, "bank", p, booth))
+ TestUtils.advanceTicks(10, false)
+
+ Assertions.assertTrue(p.bank.isOpen)
+ }
+ }
+
+ @Test
+ fun metadataSceneryCollectShouldTriggerWhenAlreadyAtRsmodApproachTile() {
+ TestUtils.getMockPlayer("bankBoothCollectApproach").use { p ->
+ val (booth, approach) = findReachableBankBoothFixture()
+ p.location = approach
+ var collected = false
+ InteractionListeners.addMetadata(
+ booth.id, IntType.SCENERY, arrayOf("collect"), InteractionListener.InteractionMetadata({ _, _, _ ->
+ collected = true
+ true
+ }, 1, false)
+ )
+
+ try {
+ Assertions.assertTrue(InteractionListeners.run(booth.id, IntType.SCENERY, "collect", p, booth))
+ TestUtils.advanceTicks(10, false)
+
+ Assertions.assertTrue(collected)
+ } finally {
+ BankBoothListener().defineListeners()
+ }
+ }
+ }
+
+ @Test
+ fun directObjectMovementPulseShouldTriggerWhenAlreadyAtRsmodApproachTile() {
+ TestUtils.getMockPlayer("objectPulseApproach").use { p ->
+ val tree =
+ RegionManager.getObject(0, 2720, 3475, 1307) ?: throw AssertionError("Expected test tree object.")
+ val approach = findReachableApproachTile(tree)
+ var pulsed = false
+ p.location = approach
+
+ GameWorld.Pulser.submit(object : MovementPulse(p, tree) {
+ override fun pulse(): Boolean {
+ pulsed = true
+ return true
+ }
+ })
+ TestUtils.advanceTicks(3, false)
+
+ Assertions.assertTrue(pulsed)
+ }
+ }
+
+ @Test
+ fun pathfinderShouldUseWrapperFootprintWhenSceneryChildIsSmallerThanWrapper() {
+ TestUtils.getMockPlayer("taverleyPatchChildPath").use { p ->
+ val wrapper = RegionManager.getObject(0, 2935, 3437, 8388)
+ ?: throw AssertionError("Expected Taverley tree patch wrapper.")
+ val child = wrapper.getChild(p)
+ val start = Location.create(2936, 3440, 0)
+
+ Assertions.assertEquals(8395, child.id)
+ Assertions.assertTrue(wrapper.definition.sizeX * wrapper.definition.sizeY > child.definition.sizeX * child.definition.sizeY)
+
+ val path = Pathfinder.find(start, child)
+ val last = path.points.lastOrNull() ?: throw AssertionError("Expected a path point.")
+ val approach = Location.create(last.x, last.y, start.z)
+
+ Assertions.assertTrue(path.isSuccessful)
+ Assertions.assertFalse(path.isMoveNear)
+ Assertions.assertEquals(start, approach)
+ }
+ }
+
+ @Test
+ fun entityMovementPulseShouldTriggerWhenDestinationOverrideIsAlreadyReached() {
+ TestUtils.getMockPlayer("bankerOverrideApproach").use { p ->
+ val npc = NPC.create(0, NPC_TEST_LOC)
+ npc.isNeverWalks = true
+ npc.init()
+ p.location = ServerConstants.HOME_LOCATION
+ var pulsed = false
+
+ GameWorld.Pulser.submit(object : MovementPulse(p, npc, DestinationFlag.ENTITY, { _, _ -> p.location }) {
+ override fun pulse(): Boolean {
+ pulsed = true
+ return true
+ }
+ })
+ TestUtils.advanceTicks(3, false)
+
+ Assertions.assertTrue(pulsed)
+ }
+ }
+
+ @Test
+ fun movingEntityMovementPulseShouldNotInteractFromDiagonalTile() {
+ TestUtils.getMockPlayer("movingNpcDiagonalApproach").use { p ->
+ val origin = Location.create(3200, 3600, 0)
+ val npc = NPC.create(0, origin.transform(1, 1, 0))
+ npc.init()
+ p.location = origin
+ p.settings.runEnergy = 100.0
+ p.settings.setRunToggled(true)
+ npc.walkingQueue.reset(false)
+ npc.walkingQueue.addPath(origin.transform(4, 1, 0).x, origin.transform(4, 1, 0).y)
+
+ var pulseLocation: Location? = null
+ var pulseTargetLocation: Location? = null
+ try {
+ GameWorld.Pulser.submit(object : MovementPulse(p, npc) {
+ override fun pulse(): Boolean {
+ pulseLocation = p.location
+ pulseTargetLocation = npc.location
+ return true
+ }
+ })
+
+ TestUtils.advanceTicks(1, false)
+ Assertions.assertNull(
+ pulseLocation, "A moving entity interaction must not trigger from a diagonal non-interaction tile."
+ )
+
+ repeat(8) {
+ if (pulseLocation == null) {
+ TestUtils.advanceTicks(1, false)
+ }
+ }
+
+ val actualPulseLocation = pulseLocation
+ ?: throw AssertionError("Expected the movement pulse to eventually reach the moving NPC.")
+ val actualTargetLocation = pulseTargetLocation
+ ?: throw AssertionError("Expected target location to be captured when the pulse fired.")
+ Assertions.assertTrue(
+ Pathfinder.canInteract(
+ actualPulseLocation.x,
+ actualPulseLocation.y,
+ p.size(),
+ actualTargetLocation.x,
+ actualTargetLocation.y,
+ npc.size(),
+ npc.size(),
+ 0,
+ actualPulseLocation.z,
+ null
+ ), "Entity interaction must fire only from a currently valid interaction tile."
+ )
+ } finally {
+ npc.clear()
+ }
+ }
+ }
+
+ @Test
+ fun entityOptionHandlerShouldNotInteractFromNpcQueuedDestination() {
+ TestUtils.getMockPlayer("queuedNpcPredictionGuard").use { p ->
+ val origin = Location.create(3200, 3600, 0)
+ val npc = NPC.create(0, origin.transform(0, 1, 0))
+ npc.init()
+ p.location = origin.transform(1, 0, 0)
+ npc.walkingQueue.reset(false)
+ npc.walkingQueue.addPath(origin.transform(4, 1, 0).x, origin.transform(4, 1, 0).y)
+
+ val optionHandler = object : OptionHandler() {
+ override fun newInstance(_arg: Any?): Plugin {
+ return this
+ }
+
+ override fun handle(_player: Player?, _node: Node?, _option: String?): Boolean {
+ return true
+ }
+ }
+ var pulseLocation: Location? = null
+ var pulseTargetLocation: Location? = null
+ try {
+ GameWorld.Pulser.submit(object : MovementPulse(p, npc, optionHandler) {
+ override fun pulse(): Boolean {
+ pulseLocation = p.location
+ pulseTargetLocation = npc.location
+ return true
+ }
+ })
+
+ TestUtils.advanceTicks(1, false)
+ Assertions.assertNull(
+ pulseLocation,
+ "Option-handler entity interaction must not fire from a tile that only reaches the NPC's queued destination."
+ )
+
+ repeat(8) {
+ if (pulseLocation == null) {
+ TestUtils.advanceTicks(1, false)
+ }
+ }
+
+ val actualPulseLocation = pulseLocation
+ ?: throw AssertionError("Expected the movement pulse to eventually reach the moving NPC.")
+ val actualTargetLocation = pulseTargetLocation
+ ?: throw AssertionError("Expected target location to be captured when the pulse fired.")
+ Assertions.assertTrue(
+ Pathfinder.canInteract(
+ actualPulseLocation.x,
+ actualPulseLocation.y,
+ p.size(),
+ actualTargetLocation.x,
+ actualTargetLocation.y,
+ npc.size(),
+ npc.size(),
+ 0,
+ actualPulseLocation.z,
+ null
+ ), "Entity interaction must fire only from a currently valid interaction tile."
+ )
+ } finally {
+ npc.clear()
+ }
+ }
+ }
+
+ @Test
+ fun entityMovementPulseShouldIgnoreMissingTargetLocation() {
+ TestUtils.getMockPlayer("missingTargetLocationGuard").use { p ->
+ val npc = NPC.create(0, NPC_TEST_LOC)
+ npc.init()
+ val originalNpcLocation = npc.location
+ var pulsed = false
+ try {
+ npc.location = null
+ val pulse = object : MovementPulse(p, npc) {
+ override fun pulse(): Boolean {
+ pulsed = true
+ return true
+ }
+ }
+
+ Assertions.assertFalse(pulse.update())
+ Assertions.assertFalse(pulsed)
+ } finally {
+ npc.location = originalNpcLocation
+ npc.clear()
+ }
+ }
+ }
+
+ @Test
+ fun entityMovementPulseShouldIgnoreMissingMoverLocation() {
+ TestUtils.getMockPlayer("missingMoverLocationGuard").use { p ->
+ val npc = NPC.create(0, NPC_TEST_LOC)
+ npc.init()
+ val locationField = Node::class.java.getDeclaredField("location")
+ locationField.isAccessible = true
+ val originalPlayerLocation = p.location
+ var pulsed = false
+ try {
+ locationField.set(p, null)
+ val pulse = object : MovementPulse(p, npc) {
+ override fun pulse(): Boolean {
+ pulsed = true
+ return true
+ }
+ }
+
+ Assertions.assertFalse(pulse.update())
+ Assertions.assertFalse(pulsed)
+ } finally {
+ locationField.set(p, originalPlayerLocation)
+ npc.clear()
+ }
+ }
+ }
+
+ @Test
+ fun runEnabledEntityMovementPulseShouldCatchWalkingNpcMovingDirectlyAway() {
+ TestUtils.getMockPlayer("runNpcInteractionChaser").use { p ->
+ val origin = openHorizontalInteractionOrigin()
+ val npc = NPC.create(0, origin.transform(4, 0, 0))
+ npc.init()
+ p.location = origin
+ p.settings.runEnergy = 100.0
+ p.settings.setRunToggled(true)
+ npc.walkingQueue.reset(false)
+ npc.walkingQueue.addPath(origin.transform(12, 0, 0).x, origin.transform(12, 0, 0).y)
+
+ var pulseLocation: Location? = null
+ var pulseTargetLocation: Location? = null
+ try {
+ GameWorld.Pulser.submit(object : MovementPulse(p, npc) {
+ override fun pulse(): Boolean {
+ pulseLocation = p.location
+ pulseTargetLocation = npc.location
+ return true
+ }
+ })
+
+ repeat(6) {
+ if (pulseLocation == null) {
+ TestUtils.advanceTicks(1, false)
+ }
+ }
+
+ val actualPulseLocation = pulseLocation ?: throw AssertionError(
+ "Run-enabled player should catch the walking NPC before it stops. " + "player=${p.location}, npc=${npc.location}, queue=${p.walkingQueue.queue}"
+ )
+ val actualTargetLocation = pulseTargetLocation
+ ?: throw AssertionError("Expected target location to be captured when the pulse fired.")
+ Assertions.assertNotEquals(
+ origin.transform(12, 0, 0),
+ actualTargetLocation,
+ "The interaction should not wait until the NPC finishes walking away."
+ )
+ Assertions.assertTrue(
+ Pathfinder.canInteract(
+ actualPulseLocation.x,
+ actualPulseLocation.y,
+ p.size(),
+ actualTargetLocation.x,
+ actualTargetLocation.y,
+ npc.size(),
+ npc.size(),
+ 0,
+ actualPulseLocation.z,
+ null
+ ), "Entity interaction must fire from a currently valid interaction tile."
+ )
+ } finally {
+ npc.clear()
+ }
+ }
+ }
+
+ @Test
+ fun interactionListenerShouldUseOptionHandlerDestinationWhenNoListenerDestinationOverride() {
+ val npc = NPC.create(0, NPC_TEST_LOC)
+ npc.isNeverWalks = true
+ npc.init()
+
+ var listenerRan = false
+ var optionHandlerRan = false
+ val optionName = "listener-custom-destination"
+ val option = Option(optionName, 4)
+ val destinationHandler = object : OptionHandler() {
+ override fun newInstance(arg: Any?): Plugin {
+ NPCDefinition.forId(0).handlers["option:$optionName"] = this
+ return this
+ }
+
+ override fun handle(player: Player?, node: Node?, option: String?): Boolean {
+ optionHandlerRan = true
+ return true
+ }
+
+ override fun getDestination(n: Node, node: Node): Location {
+ return n.location
+ }
+ }
+ destinationHandler.newInstance(null)
+ option.handler = destinationHandler
+ npc.interaction.set(option)
+ InteractionListeners.add(0, IntType.NPC.ordinal, arrayOf(optionName)) { _, _ ->
+ listenerRan = true
+ true
+ }
+
+ TestUtils.getMockPlayer("listenerOptionDestination").use { p ->
+ p.location = ServerConstants.HOME_LOCATION
+ TestUtils.simulateInteraction(p, npc, 4)
+ TestUtils.advanceTicks(3, false)
+
+ Assertions.assertTrue(listenerRan)
+ Assertions.assertFalse(optionHandlerRan)
+ Assertions.assertEquals(ServerConstants.HOME_LOCATION, p.location)
+ }
+ }
+
+ @Test
+ fun genericTalkToShouldOpenGrandExchangeClerkDialogueFromCounterApproachTile() {
+ GrandExchangePlugin().newInstance(null)
+ if (!DialogueInterpreter.contains(6528)) {
+ GrandExchangeClerk().init()
+ }
+ if (InteractionListeners.get("talk-to", IntType.NPC.ordinal) == null) {
+ NPCTalkListener().defineListeners()
+ }
+
+ val clerk = NPC.create(6528, Location.create(3165, 3491, 0), Direction.NORTH)
+ clerk.isNeverWalks = true
+ clerk.init()
+
+ try {
+ TestUtils.getMockPlayer("geClerkTalk").use { p ->
+ p.location = Location.create(3165, 3492, 0)
+
+ TestUtils.simulateInteraction(p, clerk, 0)
+ TestUtils.advanceTicks(3, false)
+
+ Assertions.assertNotNull(p.dialogueInterpreter.dialogue)
+ Assertions.assertEquals(GrandExchangeClerk::class.java, p.dialogueInterpreter.dialogue.javaClass)
+ Assertions.assertEquals(Location.create(3165, 3492, 0), p.location)
+ }
+ } finally {
+ clerk.clear()
+ }
+ }
+
+ private fun findReachableBankBoothFixture(): Pair {
+ val base = ServerConstants.HOME_LOCATION!!.transform(8, 8, 0)
+ for (rotation in 0..3) {
+ val booth = Scenery(SceneryIds.BANK_BOOTH_2213, base, 10, rotation)
+ runCatching { findReachableApproachTile(booth) }.getOrNull()?.let { return booth to it }
+ }
+ throw AssertionError("Could not find a reachable synthetic bank booth fixture.")
+ }
+
+ private fun findReachableApproachTile(scenery: Scenery): Location {
+ for (radius in 1..8) {
+ for (x in scenery.location.x - radius..scenery.location.x + radius) {
+ for (y in scenery.location.y - radius..scenery.location.y + radius) {
+ val start = Location.create(x, y, scenery.location.z)
+ if (!RegionManager.isTeleportPermitted(start)) {
+ continue
+ }
+ val path = Pathfinder.find(start, scenery)
+ if (!path.isSuccessful || path.isMoveNear) {
+ continue
+ }
+ val point = path.points.lastOrNull()
+ val approach = Location.create(point?.x ?: start.x, point?.y ?: start.y, start.z)
+ val check = Pathfinder.find(approach, scenery)
+ if (check.isSuccessful && !check.isMoveNear) {
+ return approach
+ }
+ }
+ }
+ }
+ throw AssertionError("Could not find a reachable approach tile for $scenery.")
+ }
+
+ @Test
+ fun movementPulseShouldStopEarlyIfNextToATileOccupiedByTargetObject() {
val start = Location.create(2731, 3481)
val dest = RegionManager.getObject(0, 2720, 3475, 1307)
val p = TestUtils.getMockPlayer("treefindtest")
@@ -59,15 +659,17 @@ class PathfinderTests {
Assertions.assertEquals(Location.create(2722, 3475, 0), p.location)
}
- @Test fun movementInteractionShouldTrigger() {
+ @Test
+ fun movementInteractionShouldTrigger() {
val npc = NPC.create(0, NPC_TEST_LOC)
npc.init()
var intListenerRan = false
- InteractionListeners.add(0, IntType.NPC.ordinal, arrayOf("testoptlistener"), method = {player: Player, node: Node ->
- intListenerRan = true
- return@add true
- })
+ InteractionListeners.add(
+ 0, IntType.NPC.ordinal, arrayOf("testoptlistener"), method = { player: Player, node: Node ->
+ intListenerRan = true
+ return@add true
+ })
var pluginRan = false
val option = Option("testoption", 4)
@@ -77,6 +679,7 @@ class PathfinderTests {
NPCDefinition.forId(0).handlers["option:testoption"] = this
return this
}
+
override fun handle(player: Player?, node: Node?, option: String?): Boolean {
pluginRan = true
return true
@@ -87,7 +690,7 @@ class PathfinderTests {
npc.interaction.set(option2)
option.handler = testHandler
- TestUtils.getMockPlayer("interactionTest").use {p ->
+ TestUtils.getMockPlayer("interactionTest").use { p ->
p.location = ServerConstants.HOME_LOCATION
TestUtils.simulateInteraction(p, npc, 0)
TestUtils.advanceTicks(10, false)
@@ -99,8 +702,9 @@ class PathfinderTests {
}
}
- @Test fun entityMovingToStationaryNPCShouldNotIdleIndefinitely() {
- TestUtils.getMockPlayer("idlenpcdest").use {p ->
+ @Test
+ fun entityMovingToStationaryNPCShouldNotIdleIndefinitely() {
+ TestUtils.getMockPlayer("idlenpcdest").use { p ->
val startLoc = ServerConstants.HOME_LOCATION
p.location = startLoc
val npc = NPC.create(0, NPC_TEST_LOC)
@@ -116,8 +720,9 @@ class PathfinderTests {
}
}
- @Test fun entityTargetMovementPulseShouldNotStopOnSameTileAsEntity() {
- TestUtils.getMockPlayer("entitystoptest").use {p ->
+ @Test
+ fun entityTargetMovementPulseShouldNotStopOnSameTileAsEntity() {
+ TestUtils.getMockPlayer("entitystoptest").use { p ->
p.location = ServerConstants.HOME_LOCATION
val npc = NPC.create(0, NPC_TEST_LOC)
npc.isNeverWalks = true
@@ -133,7 +738,8 @@ class PathfinderTests {
}
}
- @Test fun entityTargetMovementPulseWithExplicitParamsShouldNotStopOnSameTile() {
+ @Test
+ fun entityTargetMovementPulseWithExplicitParamsShouldNotStopOnSameTile() {
TestUtils.getMockPlayer("entitystoptest2").use { p ->
p.location = ServerConstants.HOME_LOCATION
val npc = NPC.create(0, NPC_TEST_LOC)
@@ -150,7 +756,8 @@ class PathfinderTests {
}
}
- @Test fun doubleMovementPulseToEntityShouldNotStopOnSameTile() {
+ @Test
+ fun doubleMovementPulseToEntityShouldNotStopOnSameTile() {
TestUtils.getMockPlayer("entitystoptest3").use { p ->
p.location = ServerConstants.HOME_LOCATION
val npc = NPC.create(0, NPC_TEST_LOC)
@@ -173,12 +780,14 @@ class PathfinderTests {
}
}
- @Test fun simulatedInteractionPacketWithMovementFromPluginShouldNotEndOnSameTile() {
+ @Test
+ fun simulatedInteractionPacketWithMovementFromPluginShouldNotEndOnSameTile() {
val testHandler = object : OptionHandler() {
override fun newInstance(arg: Any?): Plugin {
NPCDefinition.forId(0).handlers["option:testoption"] = this
return this
}
+
override fun handle(player: Player?, node: Node?, option: String?): Boolean {
log(this::class.java, Log.ERR, "Interaction triggered")
return true
@@ -202,14 +811,16 @@ class PathfinderTests {
}
}
- @Test fun simulatedInteractionPacketWithMovementFromListenerShouldNotEndOnSameTile() {
+ @Test
+ fun simulatedInteractionPacketWithMovementFromListenerShouldNotEndOnSameTile() {
val npc = NPC.create(0, NPC_TEST_LOC)
npc.isNeverWalks = true
npc.init()
- InteractionListeners.add(0, IntType.NPC.ordinal, arrayOf("testoptlistener2"), method = {player: Player, node: Node ->
- return@add true
- })
+ InteractionListeners.add(
+ 0, IntType.NPC.ordinal, arrayOf("testoptlistener2"), method = { player: Player, node: Node ->
+ return@add true
+ })
val opt = Option("testoptlistener2", 1)
npc.interaction.set(opt)
@@ -223,7 +834,8 @@ class PathfinderTests {
}
}
- @Test fun npcShouldReliablyReturnToSpawnLocationIfTooFar() {
+ @Test
+ fun npcShouldReliablyReturnToSpawnLocationIfTooFar() {
//spawn a player into the area just to make sure it ticks...
TestUtils.getMockPlayer("areatest").use { p ->
val npc = NPC(1, Location.create(3240, 3226, 0))
@@ -239,7 +851,106 @@ class PathfinderTests {
}
}
- @Test fun npcShouldReliablyReturnToSpawnEvenIfRegionUnloaded() {
+ @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 randomWalkingNpcShouldUseSideStepWhenDirectNorthTileIsBlocked() {
+ val origin = Location.create(3200, 3600, 0)
+ val blocked = origin.transform(0, 1, 0)
+ val destination = origin.transform(0, 2, 0)
+ val sidesteps = setOf(
+ origin.transform(-1, 0, 0), origin.transform(1, 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) {
+ if (!npc.walkingQueue.hasPath()) {
+ npc.handleTickActions()
+ }
+ }
+
+ Assertions.assertTrue(
+ npc.walkingQueue.hasPath(),
+ "Random-walking NPCs should use an open east/west sidestep when the direct north tile is blocked."
+ )
+ npc.walkingQueue.update()
+ Assertions.assertTrue(
+ npc.location in sidesteps,
+ "Random-walking NPC should only take a local sidestep, not route through the blocked north tile. " + "npc=${npc.location}"
+ )
+ } finally {
+ RegionManager.removeClippingFlag(blocked.z, blocked.x, blocked.y, false, movementBlockFlag)
+ npc.clear()
+ }
+ }
+
+ @Test
+ fun randomWalkingNpcShouldNotFullyRouteAroundBlockedLocalDestination() {
+ val origin = Location.create(3200, 3600, 0)
+ val blocked = origin.transform(1, 0, 0)
+ val destination = origin.transform(2, 0, 0)
+ val sidesteps = setOf(
+ origin.transform(0, -1, 0), origin.transform(0, 1, 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) {
+ if (!npc.walkingQueue.hasPath()) {
+ npc.handleTickActions()
+ }
+ }
+
+ Assertions.assertTrue(
+ npc.walkingQueue.hasPath(),
+ "Random-walking NPCs should use a local sidestep instead of taking an RSMOD detour."
+ )
+ npc.walkingQueue.update()
+ Assertions.assertTrue(
+ npc.location in sidesteps,
+ "Random-walking NPC should not fully route around a clipped boundary tile. npc=${npc.location}"
+ )
+ Assertions.assertNotEquals(destination, 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 ->
val npc = NPC(1, Location.create(3240, 3226, 0))
@@ -257,4 +968,45 @@ class PathfinderTests {
Assertions.assertEquals(true, npc.location.getDistance(ServerConstants.HOME_LOCATION!!) <= 5)
}
}
-}
\ No newline at end of file
+
+ 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
+
+ private fun openHorizontalInteractionOrigin(): Location {
+ val start = Location.create(3200, 3600, 0)
+ for (dy in -16..16) {
+ for (dx in -16..16) {
+ val candidate = start.transform(dx, dy, 0)
+ if ((0..12).all { RegionManager.isTeleportPermitted(candidate.transform(it, 0, 0)) } && (0..11).all {
+ Pathfinder.canInteract(
+ candidate.x + it,
+ candidate.y,
+ 1,
+ candidate.x + it + 1,
+ candidate.y,
+ 1,
+ 1,
+ 0,
+ candidate.z,
+ null
+ )
+ }) {
+ return candidate
+ }
+ }
+ }
+ throw AssertionError("No open horizontal interaction test line found near $start.")
+ }
+}
diff --git a/Server/src/test/kotlin/core/WalkingQueueTests.kt b/Server/src/test/kotlin/core/WalkingQueueTests.kt
new file mode 100644
index 000000000..211a4ee3a
--- /dev/null
+++ b/Server/src/test/kotlin/core/WalkingQueueTests.kt
@@ -0,0 +1,59 @@
+package core
+
+import TestUtils
+import core.game.world.map.Location
+import core.game.world.map.RegionManager
+import org.junit.jupiter.api.Assertions
+import org.junit.jupiter.api.Test
+
+class WalkingQueueTests {
+ companion object {
+ init {
+ TestUtils.preTestSetup()
+ }
+ }
+
+ @Test fun resetClearsDrawRouteMarkersBeforeQueuingNewDestination() {
+ TestUtils.getMockPlayer("drawRouteReset").use { player ->
+ val start = Location.create(3200, 3200, 0)
+ player.location = start
+ player.setAttribute("routedraw", true)
+
+ val queue = player.walkingQueue
+ queue.reset()
+ queue.addPath(start.x + 3, start.y)
+
+ val firstRouteItemLocation = queue.routeItems.firstOrNull()?.location
+ ?: throw AssertionError("Expected the first route to draw route markers.")
+ Assertions.assertNotNull(
+ RegionManager.getRegionPlane(firstRouteItemLocation).getItem(
+ DRAW_ROUTE_ITEM_ID,
+ firstRouteItemLocation,
+ player
+ )
+ )
+
+ try {
+ queue.reset()
+ queue.addPath(start.x, start.y + 3)
+
+ Assertions.assertNull(
+ RegionManager.getRegionPlane(firstRouteItemLocation).getItem(
+ DRAW_ROUTE_ITEM_ID,
+ firstRouteItemLocation,
+ player
+ ),
+ "The first route marker should be removed when a new movement destination resets the queue."
+ )
+ Assertions.assertTrue(
+ queue.routeItems.any { it.location != firstRouteItemLocation },
+ "Expected the second route to draw its own markers."
+ )
+ } finally {
+ queue.reset()
+ }
+ }
+ }
+}
+
+private const val DRAW_ROUTE_ITEM_ID = 13444