From fad47267607492cf934e714088b8f731a8d291b4 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Sat, 9 May 2026 15:02:12 +0300 Subject: [PATCH] Dumbed down dumb NPCs, fixed a bunch of regressions, improved ::drawroute And some new tests --- .../core/game/interaction/MovementPulse.java | 308 +++++++------ .../entity/combat/CombatMovementIntents.kt | 423 +++++++++++++++--- .../game/node/entity/combat/CombatPulse.kt | 4 +- .../node/entity/combat/MeleeSwingHandler.kt | 77 +++- .../game/node/entity/impl/WalkingQueue.java | 36 +- .../kotlin/content/CombatMovementTests.kt | 234 +++++++++- .../src/test/kotlin/core/PathfinderTests.kt | 73 +++ .../src/test/kotlin/core/WalkingQueueTests.kt | 59 +++ 8 files changed, 997 insertions(+), 217 deletions(-) create mode 100644 Server/src/test/kotlin/core/WalkingQueueTests.kt diff --git a/Server/src/main/core/game/interaction/MovementPulse.java b/Server/src/main/core/game/interaction/MovementPulse.java index 220e2d5dc..dba7bc58d 100644 --- a/Server/src/main/core/game/interaction/MovementPulse.java +++ b/Server/src/main/core/game/interaction/MovementPulse.java @@ -26,6 +26,7 @@ import content.region.wilderness.handlers.revenants.RevenantNPC; import static core.api.ContentAPIKt.*; +import java.util.ArrayList; import java.util.Deque; /** @@ -236,50 +237,38 @@ public abstract class MovementPulse extends Pulse { 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(), - null - ); - } - } - 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(); + boolean atInteractLocation = ml.equals(interactLocation); + if (destination instanceof Entity) { + boolean canInteractFromCurrentPosition = canInteractWithEntityFromCurrentPosition((Entity) destination); + if (!canInteractFromCurrentPosition && !(near && atInteractLocation) && !(hasExplicitInteractionLocation() && atInteractLocation)) { + return false; } + } else if (!atInteractLocation) { + return false; + } + 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. @@ -287,34 +276,38 @@ public abstract class MovementPulse extends Pulse { * @return true if the mover can interact without moving */ private boolean canInteractWithoutMoving() { - if (!(destination instanceof Entity)) { - return 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; - } - if (isInsideEntity(mover.getLocation())) { - return false; - } - 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; - } - } - 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(), - null - ); + 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) { + Location dl = target.getLocation(); + 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 optionHandler != null || useHandler != null || overrideMethod != null; } /** @@ -355,53 +348,53 @@ public abstract class MovementPulse extends Pulse { } 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){ + if (destination == null || destination.getLocation() == null) { return; } - + Location loc = null; - + if (optionHandler != null) { loc = optionHandler.getDestination(mover, destination); - } - else if (useHandler != null) { + } else if (useHandler != null) { loc = useHandler.getDestination((Player) mover, destination); - } - else if (isInsideEntity(mover.getLocation())) { + } 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 == 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 (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()) + 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()) { @@ -413,7 +406,7 @@ public abstract class MovementPulse extends Pulse { usingTruncatedPath = false; } near = !path.isSuccessful() || path.isMoveNear(); - + if (!path.getPoints().isEmpty()) { Point point = path.getPoints().getLast(); if (forceRun) { @@ -423,17 +416,31 @@ public abstract class MovementPulse extends Pulse { } 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 = Location.create(point.getX(), point.getY(), mover.getLocation().getZ()); + interactLocation = pointLocation; } } else if (interactLocation == null && path.isSuccessful() && !path.isMoveNear()) { interactLocation = mover.getLocation(); @@ -446,14 +453,14 @@ public abstract class MovementPulse extends Pulse { 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) + 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) { @@ -465,53 +472,84 @@ public abstract class MovementPulse extends Pulse { } 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 (points.length > 0) { - Point p = points[0]; - Point predictiveIntersection = null; - 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 - 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) { - p = points[i]; - } - } - if (predictiveIntersection != null) - p = predictiveIntersection; - - Location endLoc = getClosestBorderToPoint(p, loc.getZ()); - - if (!RegionManager.isTeleportPermitted(endLoc)) { // Basically a prayer - return loc; - } - return endLoc; - } - } - } - return loc; - } - - private Location getClosestBorderToPoint (Point p, int plane) { - Vector pathDiff = Vector.betweenLocs (destination.getLocation(), Location.create(p.getX(), p.getY(), plane)); + + 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 (hasQueuedMovement((Entity) destination)) { + Point[] points = queuedMovementPoints(wq); + if (points.length > 0) { + Point p = points[0]; + Point predictiveIntersection = null; + 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 + 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) { + p = points[i]; + } + } + if (predictiveIntersection != null) + p = predictiveIntersection; + + Location endLoc = getClosestBorderToPoint(p, loc.getZ()); + + if (!RegionManager.isTeleportPermitted(endLoc)) { // Basically a prayer + return loc; + } + return endLoc; + } + } + } + return loc; + } + + private boolean shouldTruncateEntityPath() { + return destination instanceof Entity && !hasExplicitInteractionLocation(); + } + + private boolean overlapsEntityFootprint(Location source, Entity target) { + Location targetLocation = target.getLocation(); + 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)); Location predictedCenterPos = (destination.getMathematicalCenter().plus(pathDiff)).toLocation(plane); Vector toPlayerNormalized = Vector.betweenLocs(predictedCenterPos, mover.getCenterLocation()).normalized(); return predictedCenterPos.transform(toPlayerNormalized.times(destination.size() + 1)); diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt index e99233dc6..96d4754ac 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -15,8 +15,7 @@ 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 java.util.LinkedHashMap -import org.rsmod.game.pathfinder.PathFinder as RsmodRouteFinder +import kotlin.math.abs import kotlin.math.sqrt /** @@ -37,6 +36,7 @@ object CombatMovementIntents { val location: Location get() = node.location } + private data class CandidatePath(val steps: List, val projectedLocation: Location) data class ResolveReport( val intents: Int, @@ -49,11 +49,11 @@ object CombatMovementIntents { 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}" + "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" + "directPathHits=$directPathHits rsmodRouteCalls=$rsmodRouteCalls losCalls=$losCalls$slowest" } companion object { @@ -226,13 +226,11 @@ object CombatMovementIntents { return false } val predictedTargetLocation = CombatMovementPlanner.predictedMovementLocation(target) ?: return false - val borderTiles = CombatMovementPlanner.borderTiles(target, predictedTargetLocation, attacker.size()) - val attackTiles = borderTiles.filter { RegionManager.isTeleportPermitted(it) }.ifEmpty { borderTiles } - if (attacker.location in attackTiles) { + if (canAttackFrom(attacker, target, attacker.location, predictedTargetLocation)) { return false } val projectedAttackerLocation = CombatMovementPlanner.predictedMovementLocation(attacker) ?: attacker.location - return projectedAttackerLocation !in attackTiles + return !canAttackFrom(attacker, target, projectedAttackerLocation, predictedTargetLocation) } private fun isActiveMeleeAttacker(attacker: Entity, target: Entity): Boolean { @@ -278,7 +276,14 @@ object CombatMovementIntents { val targetLocation = targetLocationFor(attacker, target) val projectedTargetLocation = projectedLocations[target] - if (projectedTargetLocation != null && canAttackFrom(attacker, target, attacker.location, projectedTargetLocation, trace)) { + if (projectedTargetLocation != null && canAttackFrom( + attacker, + target, + attacker.location, + projectedTargetLocation, + trace + ) + ) { attacker.walkingQueue.reset() attacker.face(target) projectedLocations[attacker] = attacker.location @@ -297,43 +302,54 @@ object CombatMovementIntents { if (shouldUseTargetFootprintRoute(attacker, target, pathfinder)) { val candidatePath = pathToTargetFootprint(attacker, target, targetLocation, pathfinder, trace) if (candidatePath != null) { - if (hasReservedOccupiedTile(reservedTiles, attacker, candidatePath.projectedLocation)) { + val attackPath = + truncateAtFirstAttackOpportunity(attacker, target, targetLocation, candidatePath, trace) + if (attackPath != null) { + if (hasReservedOccupiedTile(reservedTiles, attacker, attackPath.projectedLocation)) { + return + } + walkPath(attacker, attackPath) + attacker.face(target) + projectedLocations[attacker] = attackPath.projectedLocation + reserveOccupiedTiles(reservedTiles, attacker, attackPath.projectedLocation) return } - walkPath(attacker, candidatePath) - attacker.face(target) - projectedLocations[attacker] = candidatePath.projectedLocation - reserveOccupiedTiles(reservedTiles, attacker, candidatePath.projectedLocation) - return } - if (shouldStopUnreachableCombat(attacker, target)) { - stopUnreachableCombat(attacker) - } - return } val candidates = movementDestinationsFor(attacker, target, targetLocation, pathfinder, trace) val standingOnCandidate = candidates.any { it.location == attacker.location } val projectedAttackerLocation = CombatMovementPlanner.predictedMovementLocation(attacker) ?: attacker.location - if (projectedAttackerLocation != attacker.location && canAttackFrom(attacker, target, projectedAttackerLocation, targetLocation, trace)) { + if (projectedAttackerLocation != attacker.location && canAttackFrom( + attacker, + target, + projectedAttackerLocation, + targetLocation, + trace + ) + ) { attacker.face(target) projectedLocations[attacker] = projectedAttackerLocation reserveOccupiedTiles(reservedTiles, attacker, projectedAttackerLocation) return } + val queueStationaryContinuation = attacker.properties.combatPulse.style == CombatStyle.MELEE && + !CombatMovementPlanner.hasMovementStepThisTick(target) var blockedByReservation = false for (candidate in candidates) { - val candidatePath = pathTo(attacker, candidate, trace) ?: continue - if (hasReservedOccupiedTile(reservedTiles, attacker, candidatePath.projectedLocation)) { + 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, candidatePath) + walkPath(attacker, attackPath) attacker.face(target) - projectedLocations[attacker] = candidatePath.projectedLocation - reserveOccupiedTiles(reservedTiles, attacker, candidatePath.projectedLocation) + projectedLocations[attacker] = attackPath.projectedLocation + reserveOccupiedTiles(reservedTiles, attacker, attackPath.projectedLocation) return } if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target, standingOnCandidate)) { @@ -376,7 +392,14 @@ object CombatMovementIntents { return false } return when (attacker.properties.combatPulse.style) { - CombatStyle.RANGE, CombatStyle.MAGIC -> canAttackFromRange(attacker, target, attackerLocation, targetLocation, trace) + CombatStyle.RANGE, CombatStyle.MAGIC -> canAttackFromRange( + attacker, + target, + attackerLocation, + targetLocation, + trace + ) + else -> canAttackFromMelee(attacker, target, attackerLocation, targetLocation, trace) } } @@ -401,7 +424,7 @@ object CombatMovementIntents { return destinations } - val attackTiles = CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation) + val attackTiles = attackTilesFor(attacker, target, targetLocation, trace) if (attacker is Player) { val rangedTiles = playerAttackRangeDestinations(attacker, target, targetLocation, pathfinder, trace) val capacity = rangedTiles.size + attackTiles.size + 1 @@ -432,6 +455,27 @@ object CombatMovementIntents { return destinations } + 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) } + 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)) { @@ -482,7 +526,7 @@ object CombatMovementIntents { 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) + rangeWeapon.weaponType == Weapon.WeaponType.DEGRADING) ) { distance = 10 } @@ -521,7 +565,15 @@ object CombatMovementIntents { val attackTiles = ArrayList(minOf(MAX_RANGED_APPROACH_CANDIDATES, tiles.size)) RsmodPathfinder.loadLineOfSightWindow(targetLocation) for (tile in tiles) { - if (!hasProjectileLineOfSight(tile, attacker.size(), target, targetLocation, loadWindow = false, trace = trace)) { + if (!hasProjectileLineOfSight( + tile, + attacker.size(), + target, + targetLocation, + loadWindow = false, + trace = trace + ) + ) { continue } attackTiles.add(tile) @@ -575,7 +627,7 @@ object CombatMovementIntents { ) } return distanceSquaredToClosestOccupiedTile(target, targetLocation, attackerLocation) <= range * range && - hasProjectileLineOfSight(attackerLocation, attacker.size(), target, targetLocation, trace = trace) + hasProjectileLineOfSight(attackerLocation, attacker.size(), target, targetLocation, trace = trace) } private fun isAdjacentToTarget( @@ -700,11 +752,25 @@ object CombatMovementIntents { pathfinder: Pathfinder, trace: IntentTrace ): CandidatePath? { - val directDestination = naiveMeleeDestination(attacker, target, targetLocation) - val directPath = directPathTo(attacker, directDestination) - if (directPath != null) { + val targetMoving = CombatMovementPlanner.hasMovementStepThisTick(target) + val movingTargetPath = + if (targetMoving) directPathTowardMovingTarget(attacker, target, targetLocation) else null + if (movingTargetPath != null) { trace.directPathHits++ - return directPath + return movingTargetPath + } + for (directDestination in preferredMeleeDestinations(attacker, target, targetLocation)) { + 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 @@ -731,32 +797,148 @@ object CombatMovementIntents { if (!path.isSuccessful || path.points.isEmpty()) { return null } - if (attacker is Player && !path.isMoveNear && isExcessiveCombatDetourToTarget(attacker, target, targetLocation, path)) { + 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)) { + 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)) }) { + 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 naiveMeleeDestination(attacker: Entity, target: Entity, targetLocation: Location): Location { - val destination = RsmodRouteFinder.naiveDestination( - sourceX = attacker.location.x, - sourceZ = attacker.location.y, - sourceWidth = attacker.size(), - sourceHeight = attacker.size(), - targetX = targetLocation.x, - targetZ = targetLocation.y, - targetWidth = target.size(), - targetHeight = target.size() - ) - return Location.create(destination.x, destination.z, targetLocation.z) + 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 addDumbNpcAttackDestinations( @@ -809,16 +991,19 @@ object CombatMovementIntents { 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(), @@ -827,11 +1012,46 @@ object CombatMovementIntents { } } - private fun pathTo(attacker: Entity, destination: MovementDestination, trace: IntentTrace): CandidatePath? { + 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 directPath = directPathTo(attacker, destination) + val stepLimit = movementStepLimit(attacker, queueContinuation && !destination.allowPartialPath) + if (attacker is NPC && destination.pathfinder === Pathfinder.DUMB) { + val directPath = directPartialPathTo(attacker, destination, stepLimit) + if (directPath != null) { + trace.directPathHits++ + return directPath + } + return null + } + val directPath = directPathTo(attacker, destination, stepLimit) if (directPath != null) { trace.directPathHits++ return directPath @@ -847,32 +1067,57 @@ object CombatMovementIntents { if (!path.reaches(destination.location) && (!destination.allowPartialPath || path.points.isEmpty())) { return null } - if (attacker is Player && !destination.allowPartialPath && isExcessiveCombatDetour(attacker, destination, path)) { + if (attacker is Player && !destination.allowPartialPath && isExcessiveCombatDetour( + attacker, + destination, + path + ) + ) { return null } - val steps = immediateMovementSteps(attacker, path) - if (attacker is Player && destination.allowPartialPath && !partialPathMovesCloser(attacker, destination, steps)) { + 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)) }) { + 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): CandidatePath? { + 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) + return directPathTo(attacker, destination.location, maxSteps) } - private fun directPathTo(attacker: Entity, destination: Location): CandidatePath? { + private fun directPathTo( + attacker: Entity, + destination: Location, + maxSteps: Int = movementStepsFor(attacker) + ): CandidatePath? { if (attacker.size() != 1 || attacker.location.z != destination.z) { return null } - val maxSteps = movementStepsFor(attacker) val steps = ArrayList(maxSteps) var current = attacker.location var distance = 0 @@ -900,6 +1145,36 @@ object CombatMovementIntents { return CandidatePath(steps, projected) } + private fun directPartialPathTo( + attacker: Entity, + destination: MovementDestination, + maxSteps: Int + ): CandidatePath? { + if (attacker.size() != 1 || destination.node !is Location || attacker.location.z != destination.location.z) { + return null + } + val steps = ArrayList(maxSteps) + var current = attacker.location + var distance = 0 + while (current != destination.location && steps.size < maxSteps) { + if (++distance > MAX_DIRECT_COMBAT_PATH_DISTANCE) { + break + } + val direction = Direction.getDirection(current, destination.location) ?: break + if (!direction.canMoveFrom(current.z, current.x, current.y, RegionManager::getClippingFlag)) { + break + } + val next = current.transform(direction) + if (!RegionManager.isTeleportPermitted(next)) { + break + } + steps.add(Point(next.x, next.y, direction, direction.stepX, direction.stepY)) + current = next + } + val projected = steps.lastOrNull()?.let { Location.create(it.x, it.y, attacker.location.z) } ?: return null + return CandidatePath(steps, projected) + } + private fun isExcessiveCombatDetourToTarget( attacker: Player, target: Entity, @@ -907,7 +1182,8 @@ object CombatMovementIntents { path: Path ): Boolean { val pathLength = (path.points.size - 1).coerceAtLeast(0) - val directDistance = sqrt(distanceSquaredToClosestOccupiedTile(target, targetLocation, attacker.location).toDouble()) + val directDistance = + sqrt(distanceSquaredToClosestOccupiedTile(target, targetLocation, attacker.location).toDouble()) return pathLength > directDistance + MAX_PLAYER_COMBAT_PATH_DETOUR } @@ -919,7 +1195,7 @@ object CombatMovementIntents { ): 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) + distanceSquaredToClosestOccupiedTile(target, targetLocation, attacker.location) } private fun isExcessiveCombatDetour(attacker: Player, destination: MovementDestination, path: Path): Boolean { @@ -928,7 +1204,11 @@ object CombatMovementIntents { return pathLength > directDistance + MAX_PLAYER_COMBAT_PATH_DETOUR } - private fun partialPathMovesCloser(attacker: Player, destination: MovementDestination, steps: List): Boolean { + 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) } @@ -941,8 +1221,11 @@ object CombatMovementIntents { return terminal.x == destination.x && terminal.y == destination.y } - private fun immediateMovementSteps(attacker: Entity, path: Path): List { - val maxSteps = movementStepsFor(attacker) + 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) { @@ -979,6 +1262,10 @@ object CombatMovementIntents { } } + 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 } @@ -1037,9 +1324,9 @@ object CombatMovementIntents { 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 + firstLocation.x + first.size() > secondLocation.x && + firstLocation.y < secondLocation.y + second.size() && + firstLocation.y + first.size() > secondLocation.y } private fun pathfinderFor(attacker: Entity): Pathfinder { 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 06f5c8cd1..e9dce99dd 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt @@ -321,8 +321,10 @@ class CombatPulse( victim.scripts.removeWeakScripts() } - if (!isAttacking) { + if (!isAttacking) entity.pulseManager.run(this) + if (victim is Entity && style == CombatStyle.MELEE) { + CombatMovementIntents.trackActiveMelee(entity, victim) } } 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 52d11bfd2..ded768d6b 100644 --- a/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt @@ -15,6 +15,10 @@ import core.game.node.entity.npc.NPC import core.game.node.entity.player.Player import core.game.node.entity.skill.Skills import core.tools.RandomFunction +import core.game.world.map.Direction +import core.game.world.map.Location +import core.game.world.map.RegionManager +import core.game.world.map.path.RsmodPathfinder import org.rs09.consts.Items import org.rs09.consts.NPCs import kotlin.math.floor @@ -39,7 +43,7 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag) distance += if (entity.walkingQueue.isRunningBoth) 2 else 1 goodRange = CombatReach.canMelee(entity, victim, distance) } - if (!isProjectileClipped(entity, victim, !CombatReach.isUsingHalberd(entity))) { + if (!hasMeleeLineOfSight(entity, victim, type)) { return InteractionType.NO_INTERACT } val isRunning = entity.walkingQueue.runDir != -1 @@ -58,6 +62,77 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag) return InteractionType.NO_INTERACT } + private fun hasMeleeLineOfSight(entity: Entity, victim: Entity, type: InteractionType): Boolean { + val checkClose = !CombatReach.isUsingHalberd(entity) + if (isProjectileClipped(entity, victim, checkClose)) { + return true + } + if (type != InteractionType.MOVE_INTERACT || !checkClose) { + 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 RsmodPathfinder.hasLineOfSightBetween( + projectedEntityLocation, + entity.size(), + predictedVictimLocation, + victim.size(), + maxRaySteps = 1 + ) + } + + 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 state!!.style = CombatStyle.MELEE 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 1cc1a32d3..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) { @@ -375,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; } /** @@ -413,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/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index a47b914b6..1ca6d3286 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -34,6 +34,7 @@ import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.rsmod.game.pathfinder.flag.CollisionFlag import kotlin.math.abs class CombatMovementTests { @@ -79,6 +80,8 @@ class CombatMovementTests { enableRun(attacker) enableRun(victim) enablePvp(attacker, victim) + attacker.playerFlags.setUpdateSceneGraph(false) + victim.playerFlags.setUpdateSceneGraph(false) CombatMovementIntents.clear() attacker.attack(victim) @@ -304,6 +307,92 @@ class CombatMovementTests { } } + @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 + ) + 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( + predictedVictim.z, + predictedVictim.x, + predictedVictim.y, + true, + CollisionFlag.WALL_SOUTH_PROJECTILE_BLOCKER + ) + CombatMovementIntents.clear() + } + } + } + } + @Test fun overlappingMeleePlayerShouldStepToOpenAttackTileInsteadOfStopping() { TestUtils.getMockPlayer("combat_overlap_player_escape").use { player -> @@ -451,6 +540,94 @@ class CombatMovementTests { } } + @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() + try { + configureMelee(npc) + blockMovementTiles(blockedTiles) + RegionManager.addClippingFlag( + npcLocation.z, + npcLocation.x, + npcLocation.y, + true, + CollisionFlag.WALL_WEST_PROJECTILE_BLOCKER + ) + + player.attack(npc) + CombatMovementIntents.clear() + CombatMovementIntents.request(player, npc) + CombatMovementIntents.resolve() + player.walkingQueue.update() + + assertTrue( + player.location.x < npcLocation.x, + "Melee movement must stop before crossing the NPC footprint when the far-side tile is chosen. " + + "player=${player.location}, npc=$npcLocation, ${CombatMovementIntents.lastResolveSummary()}" + ) + assertNotEquals(npcLocation, player.location, "The player must not run onto the NPC footprint.") + } finally { + RegionManager.removeClippingFlag( + npcLocation.z, + npcLocation.x, + npcLocation.y, + true, + CollisionFlag.WALL_WEST_PROJECTILE_BLOCKER + ) + unblockMovementTiles(blockedTiles) + npc.clear() + CombatMovementIntents.clear() + } + } + } + @Test fun movementLockedMeleeAttackerShouldNotMoveButCanAttackIfAlreadyInRange() { TestUtils.getMockPlayer("combat_locked_attacker").use { attacker -> @@ -904,7 +1081,7 @@ class CombatMovementTests { } @Test - fun dumbMeleeNpcShouldNotRouteAroundSafespotObstacle() { + fun defaultMeleeNpcShouldNotRouteAroundSafespotObstacle() { TestUtils.getMockPlayer("combat_safespot_target").use { player -> val origin = arenaOrigin() val blockedTiles = listOf( @@ -1101,10 +1278,65 @@ class CombatMovementTests { } } + @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) diff --git a/Server/src/test/kotlin/core/PathfinderTests.kt b/Server/src/test/kotlin/core/PathfinderTests.kt index 6c4d147ca..a3ff67fad 100644 --- a/Server/src/test/kotlin/core/PathfinderTests.kt +++ b/Server/src/test/kotlin/core/PathfinderTests.kt @@ -93,6 +93,20 @@ class PathfinderTests { Assertions.assertTrue(path.points.isNotEmpty()) } + @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) @@ -212,6 +226,65 @@ class PathfinderTests { } } + @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 interactionListenerShouldUseOptionHandlerDestinationWhenNoListenerDestinationOverride() { val npc = NPC.create(0, NPC_TEST_LOC) npc.isNeverWalks = true 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