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