Dumbed down dumb NPCs, fixed a bunch of regressions, improved ::drawroute

And some new tests
This commit is contained in:
dam 2026-05-09 15:02:12 +03:00
parent 894674a12f
commit fad4726760
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
8 changed files with 997 additions and 217 deletions

View file

@ -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<Boolean, Location> 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<Point> 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<Point> 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));

View file

@ -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<Point>, 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<Location> {
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<Location> { 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<Location>(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<Point>(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<Location> {
val directions = ArrayList<Direction>(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<Point>(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<Point>(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<Point>(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<Point>): Boolean {
private fun partialPathMovesCloser(
attacker: Player,
destination: MovementDestination,
steps: List<Point>
): 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<Point> {
val maxSteps = movementStepsFor(attacker)
private fun immediateMovementSteps(
attacker: Entity,
path: Path,
maxSteps: Int = movementStepsFor(attacker)
): List<Point> {
val steps = ArrayList<Point>(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 {

View file

@ -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)
}
}

View file

@ -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

View file

@ -64,6 +64,21 @@ public final class WalkingQueue {
public ArrayList<GroundItem> routeItems = new ArrayList<GroundItem>();
/**
* 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;

View file

@ -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)

View file

@ -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

View file

@ -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