From b1231c749f9899854a91cd7f587270dc6188b61b Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Fri, 17 Jul 2026 15:00:27 +0300 Subject: [PATCH] Lazy ranged path batching, projectile edge case fix, Bork fix --- .../entity/combat/CombatMovementIntents.kt | 100 +++++---- .../game/node/entity/combat/CombatReach.kt | 11 +- .../node/entity/combat/CombatSwingHandler.kt | 2 +- .../node/entity/combat/MeleeSwingHandler.kt | 4 +- .../game/world/map/path/RsmodPathfinder.kt | 4 +- .../kotlin/content/CombatMovementTests.kt | 208 ++++++++++++++++++ .../src/test/kotlin/core/PathfinderTests.kt | 50 +++++ Server/src/test/resources/cache | Bin 19 -> 46 bytes 8 files changed, 326 insertions(+), 53 deletions(-) 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 cf9f076d6..8b3093823 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -27,7 +27,7 @@ import kotlin.math.sqrt * ticked. */ object CombatMovementIntents { - private const val MAX_RANGED_APPROACH_CANDIDATES = 16 + private const val RANGED_APPROACH_BATCH_SIZE = 16 private const val MAX_PLAYER_COMBAT_PATH_DETOUR = 6.0 private const val MAX_DIRECT_COMBAT_PATH_DISTANCE = 32 @@ -376,13 +376,16 @@ object CombatMovementIntents { val candidates = movementDestinationsFor(attacker, target, targetLocation, pathfinder, trace) - val standingOnCandidate = candidates.any { it.location == attacker.location } val queueStationaryContinuation = attacker.properties.combatPulse.style == CombatStyle.MELEE && !CombatMovementPlanner.hasMovementStepThisTick(target) + var standingOnCandidate = false var blockedByReservation = false for (candidate in candidates) { + if (candidate.location == attacker.location) { + standingOnCandidate = true + } val candidatePath = pathTo(attacker, candidate, trace, queueStationaryContinuation) ?: continue val attackPath = @@ -475,7 +478,7 @@ object CombatMovementIntents { targetLocation: Location, pathfinder: Pathfinder, trace: IntentTrace, - ): List { + ): Sequence { if (attacker is NPC && pathfinder === Pathfinder.DUMB) { val destinations = ArrayList(4) if (occupiedTilesOverlap(attacker, target)) { @@ -507,21 +510,11 @@ object CombatMovementIntents { } } trace.candidateCount += destinations.size - return destinations + return destinations.asSequence() } 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 - val destinations = ArrayList(capacity) - destinations.addAll(rangedTiles) - for (location in attackTiles) { - destinations.add( - MovementDestination(location, pathfinder, allowPartialPath = false) - ) - } val targetFallback = if ( !occupiedTilesOverlap(attacker, target) && @@ -535,11 +528,25 @@ object CombatMovementIntents { } else { null } - if (targetFallback != null) { - destinations.add(targetFallback) + return sequence { + yieldAll( + playerAttackRangeDestinations( + attacker, + target, + targetLocation, + pathfinder, + trace, + ) + ) + for (location in attackTiles) { + trace.candidateCount++ + yield(MovementDestination(location, pathfinder, allowPartialPath = false)) + } + if (targetFallback != null) { + trace.candidateCount++ + yield(targetFallback) + } } - trace.candidateCount += destinations.size - return destinations } val destinations = ArrayList(attackTiles.size) @@ -547,7 +554,7 @@ object CombatMovementIntents { destinations.add(MovementDestination(location, pathfinder, allowPartialPath = false)) } trace.candidateCount += destinations.size - return destinations + return destinations.asSequence() } private fun attackTilesFor( @@ -605,17 +612,16 @@ object CombatMovementIntents { targetLocation: Location, pathfinder: Pathfinder, trace: IntentTrace, - ): List { + ): Sequence { val range = playerAttackRange(attacker) if (range <= CombatReach.meleeDistance(attacker)) { - return emptyList() + return emptySequence() } val attackTiles = attackRangeTiles(attacker, target, targetLocation, range, trace) - val destinations = ArrayList(attackTiles.size) - for (tile in attackTiles) { - destinations.add(MovementDestination(tile, pathfinder, allowPartialPath = false)) + return attackTiles.map { tile -> + trace.candidateCount++ + MovementDestination(tile, pathfinder, allowPartialPath = false) } - return destinations } private fun playerAttackRange(attacker: Player): Int { @@ -654,7 +660,7 @@ object CombatMovementIntents { targetLocation: Location, range: Int, trace: IntentTrace, - ): List { + ): Sequence { val tiles = ArrayList() val minX = targetLocation.x - range val maxX = targetLocation.x + target.size() - 1 + range @@ -685,27 +691,29 @@ object CombatMovementIntents { .thenBy { it.x } .thenBy { it.y } ) - 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, - ) - ) { - continue - } - attackTiles.add(tile) - if (attackTiles.size >= MAX_RANGED_APPROACH_CANDIDATES) { - break + return sequence { + var nextTile = 0 + while (nextTile < tiles.size) { + val batch = ArrayList(RANGED_APPROACH_BATCH_SIZE) + RsmodPathfinder.loadLineOfSightWindow(targetLocation) + while (nextTile < tiles.size && batch.size < RANGED_APPROACH_BATCH_SIZE) { + val tile = tiles[nextTile++] + if ( + hasProjectileLineOfSight( + tile, + attacker.size(), + target, + targetLocation, + loadWindow = false, + trace = trace, + ) + ) { + batch.add(tile) + } + } + yieldAll(batch) } } - return attackTiles } private fun canAttackFromMelee( @@ -731,7 +739,7 @@ object CombatMovementIntents { ) { return false } - if (CombatReach.isUsingHalberd(attacker)) { + if (CombatReach.hasExtendedMeleeReach(attacker)) { return hasProjectileLineOfSight( attackerLocation, attacker.size(), diff --git a/Server/src/main/core/game/node/entity/combat/CombatReach.kt b/Server/src/main/core/game/node/entity/combat/CombatReach.kt index f384b2c9f..73e7a62d8 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatReach.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatReach.kt @@ -13,6 +13,8 @@ import core.game.world.map.path.RsmodPathfinder /** Shared combat reach calculations. */ object CombatReach { + private const val BORK_LEGION_ID = 7135 + @JvmStatic fun isUsingHalberd(entity: Entity): Boolean { if (entity is Player) { @@ -28,7 +30,12 @@ object CombatReach { @JvmStatic fun meleeDistance(entity: Entity): Int { - return if (isUsingHalberd(entity)) 2 else 1 + return if (hasExtendedMeleeReach(entity)) 2 else 1 + } + + @JvmStatic + fun hasExtendedMeleeReach(entity: Entity): Boolean { + return isUsingHalberd(entity) || entity is NPC && entity.id == BORK_LEGION_ID } @JvmStatic @@ -37,7 +44,7 @@ object CombatReach { if (victim == null) { return false } - if (entity.id == 7135 && entity.location.withinDistance(victim.location, 2)) { + if (entity.id == BORK_LEGION_ID && entity.location.withinDistance(victim.location, 2)) { return true } if (occupiedAreasOverlap(entity, victim)) { diff --git a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt index bc09dd687..013f3ad09 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt @@ -236,7 +236,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) { return InteractionType.NO_INTERACT } - if (type == CombatStyle.MELEE && !CombatReach.isUsingHalberd(entity)) { + if (type == CombatStyle.MELEE && !CombatReach.hasExtendedMeleeReach(entity)) { val stepType = canStepTowards(entity, victim) if (stepType != InteractionType.STILL_INTERACT) return stepType } 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 8bde6e2dc..fa4c7daba 100644 --- a/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt @@ -60,7 +60,7 @@ open class MeleeSwingHandler(vararg flags: SwingHandlerFlag) return type } else if (goodRange) { if ( - !CombatReach.isUsingHalberd(entity) && + !CombatReach.hasExtendedMeleeReach(entity) && canStepTowards(entity, victim) == InteractionType.NO_INTERACT ) return InteractionType.NO_INTERACT @@ -76,7 +76,7 @@ open class MeleeSwingHandler(vararg flags: SwingHandlerFlag) victim: Entity, type: InteractionType, ): Boolean { - if (CombatReach.isUsingHalberd(entity)) { + if (CombatReach.hasExtendedMeleeReach(entity)) { return isProjectileClipped(entity, victim, false) } if ( diff --git a/Server/src/main/core/game/world/map/path/RsmodPathfinder.kt b/Server/src/main/core/game/world/map/path/RsmodPathfinder.kt index 592ff800c..6241a9955 100644 --- a/Server/src/main/core/game/world/map/path/RsmodPathfinder.kt +++ b/Server/src/main/core/game/world/map/path/RsmodPathfinder.kt @@ -206,8 +206,8 @@ class RsmodPathfinder(private val maxWaypoints: Int = 25) : Pathfinder() { destX = dest.x, destZ = dest.y, srcSize = moverSize, - destWidth = destWidth, - destHeight = destHeight, + destWidth = destWidth.coerceAtLeast(1), + destHeight = destHeight.coerceAtLeast(1), ) } diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index 30019c57e..b48e7681e 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -20,6 +20,7 @@ import core.game.world.GameWorld import core.game.world.map.Location import core.game.world.map.RegionManager import core.game.world.map.path.Pathfinder +import core.game.world.map.path.RsmodPathfinder import core.net.packet.PacketProcessor import core.net.packet.`in`.Packet import core.plugin.Plugin @@ -967,6 +968,143 @@ class CombatMovementTests { } } + @Test + fun rangedMovementShouldTryAnotherApproachBatchWhenTheFirstIsUnrouteable() { + TestUtils.getMockPlayer("combat_ranged_later_batch").use { player -> + val origin = openHorizontalOrigin() + val npcLocation = origin.transform(6, 0, 0) + place(player, origin) + configureRanged(player) + disableRun(player) + player.playerFlags.setUpdateSceneGraph(false) + + val npc = stationaryNpc(100, npcLocation) + val projectileFlag = CollisionFlag.WALL_WEST_PROJECTILE_BLOCKER + val fencedEdges = ArrayList>() + try { + RegionManager.addClippingFlag( + npcLocation.z, + npcLocation.x, + npcLocation.y, + true, + projectileFlag, + ) + assertFalse( + CombatSwingHandler.isProjectileClipped(player, npc, false), + "The starting tile must be in nominal range but projectile-blocked.", + ) + + val orderedCandidates = rangedApproachCandidates(player, npc, range = 7) + assertTrue( + orderedCandidates.size > 16, + "The fixture needs a routeable ranged candidate after the first batch.", + ) + val enclosedTiles = + LinkedHashSet().apply { + addAll(orderedCandidates.take(32)) + addAll( + CombatMovementPlanner.candidateAttackTiles( + player, + npc, + npc.location, + ) + ) + } + fencedEdges.addAll(perimeterEdges(enclosedTiles)) + for ((inside, outside) in fencedEdges) { + setFence(inside, outside, add = true) + } + + val fencedCandidates = rangedApproachCandidates(player, npc, range = 7) + val firstBatch = fencedCandidates.take(16) + assertTrue( + firstBatch.none { candidate -> + Pathfinder.SMART.find( + player.location, + player.size(), + candidate, + 0, + 0, + 0, + -1, + 0, + false, + null, + ).isSuccessful + }, + "Every candidate in the first ranged batch must be unrouteable.", + ) + player.attack(npc) + TestUtils.advanceTicks(1, false) + + val summary = CombatMovementIntents.lastResolveSummary() + val routeCalls = + Regex("rsmodRouteCalls=(\\d+)") + .find(summary) + ?.groupValues + ?.get(1) + ?.toInt() + ?: 0 + assertTrue( + routeCalls >= 32, + "Ranged movement must continue checking routes after the first 16 fail. $summary", + ) + } finally { + for ((inside, outside) in fencedEdges) { + setFence(inside, outside, add = false) + } + RegionManager.removeClippingFlag( + npcLocation.z, + npcLocation.x, + npcLocation.y, + true, + projectileFlag, + ) + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun borkLegionShouldAttackFromItsAuthenticTwoTileMeleeReach() { + TestUtils.getMockPlayer("combat_bork_legion_target").use { player -> + val origin = openHorizontalOrigin() + val legionLocation = origin.transform(2, 0, 0) + place(player, origin) + + val legion = NPC.create(7135, legionLocation, player) + legion.init() + try { + configureMelee(legion) + + assertTrue(CombatReach.hasExtendedMeleeReach(legion)) + assertEquals(2, CombatReach.meleeDistance(legion)) + assertEquals( + InteractionType.STILL_INTERACT, + legion.getSwingHandler(false).canSwing(legion, player), + "Bork's legion should be able to swing from two clear tiles away.", + ) + + legion.attack(player) + CombatMovementIntents.clear() + CombatMovementIntents.request(legion, player) + CombatMovementIntents.resolve() + legion.walkingQueue.update() + + assertEquals( + legionLocation, + legion.location, + "The combat movement intent must not force Bork's legion into adjacency.", + ) + assertTrue(legion.properties.combatPulse.isAttacking) + } finally { + legion.clear() + CombatMovementIntents.clear() + } + } + } + @Test fun magicAutocastShouldPathToProjectileClearLumbridgeRiverDuckTile() { TestUtils.getMockPlayer("combat_magic_duck_attacker").use { player -> @@ -1849,6 +1987,76 @@ class CombatMovementTests { ) } + private fun rangedApproachCandidates( + attacker: Entity, + target: Entity, + range: Int, + ): List { + val targetLocation = target.location + val rangeSquared = range * range + val candidates = ArrayList() + for (x in targetLocation.x - range..targetLocation.x + target.size() - 1 + range) { + for (y in targetLocation.y - range..targetLocation.y + target.size() - 1 + range) { + val tile = Location.create(x, y, targetLocation.z) + val closestX = x.coerceIn(targetLocation.x, targetLocation.x + target.size() - 1) + val closestY = y.coerceIn(targetLocation.y, targetLocation.y + target.size() - 1) + val targetDx = x - closestX + val targetDy = y - closestY + if ( + targetDx * targetDx + targetDy * targetDy <= rangeSquared && + RegionManager.isTeleportPermitted(tile) + ) { + candidates.add(tile) + } + } + } + return candidates + .sortedWith( + compareBy { + val dx = it.x - attacker.location.x + val dy = it.y - attacker.location.y + dx * dx + dy * dy + } + .thenBy { + val closestX = + it.x.coerceIn(targetLocation.x, targetLocation.x + target.size() - 1) + val closestY = + it.y.coerceIn(targetLocation.y, targetLocation.y + target.size() - 1) + val dx = it.x - closestX + val dy = it.y - closestY + dx * dx + dy * dy + } + .thenBy { it.x } + .thenBy { it.y } + ) + .filter { + RsmodPathfinder.hasLineOfSightBetween( + it, + attacker.size(), + targetLocation, + target.size(), + ) + } + } + + private fun perimeterEdges(tiles: Set): List> { + val edges = ArrayList>() + for (tile in tiles) { + for (neighbour in + listOf( + tile.transform(0, 1, 0), + tile.transform(1, 0, 0), + tile.transform(0, -1, 0), + tile.transform(-1, 0, 0), + )) { + if (neighbour !in tiles) { + edges.add(tile to neighbour) + } + } + } + return edges + } + private fun enablePvp(first: Entity, second: Entity) { core.game.world.GameWorld.settings!!.wild_pvp_enabled = true first.asPlayer().skullManager.isWilderness = true diff --git a/Server/src/test/kotlin/core/PathfinderTests.kt b/Server/src/test/kotlin/core/PathfinderTests.kt index 418f62933..081551db1 100644 --- a/Server/src/test/kotlin/core/PathfinderTests.kt +++ b/Server/src/test/kotlin/core/PathfinderTests.kt @@ -151,6 +151,56 @@ class PathfinderTests { } } + @Test + fun projectilePathfinderShouldTreatZeroSizedLocationDestinationsAsOneTile() { + val start = Location.create(3204, 3204, 0) + val destinations = + listOf( + Location.create(3202, 3204, 0), + Location.create(3204, 3202, 0), + ) + val fixtureTiles = + (3201..3204).flatMap { x -> + (3201..3204).map { y -> Location.create(x, y, 0) } + } + RegionManager.loadClippingWindow(start, 128) + val originalFlags = fixtureTiles.associateWith { + RegionManager.getProjectileFlag(it.z, it.x, it.y) + } + try { + for (tile in fixtureTiles) { + RegionManager.setRsmodFlag(tile.z, tile.x, tile.y, true, 0) + } + + for (destination in destinations) { + val path = + Pathfinder.PROJECTILE.find( + start, + 1, + destination, + 0, + 0, + 0, + -1, + 0, + false, + null, + ) + + Assertions.assertTrue(path.isSuccessful) + Assertions.assertEquals( + destination, + Location.create(path.points.last.x, path.points.last.y, start.z), + "A west/south projectile ray must stop on its zero-sized Location destination.", + ) + } + } finally { + for ((tile, flag) in originalFlags) { + RegionManager.setRsmodFlag(tile.z, tile.x, tile.y, true, flag) + } + } + } + @Test fun metadataSceneryInteractionShouldTriggerWhenAlreadyAtRsmodApproachTile() { TestUtils.getMockPlayer("bankBoothApproach").use { p -> diff --git a/Server/src/test/resources/cache b/Server/src/test/resources/cache index 7af909218d6df30564154724b18219c68a34a639..1cb189aaa3a37edc7a690200571219f12b0aeb9f 120000 GIT binary patch literal 46 ocmeawE2;4D^Jdgz&|}aCVg#AOkjPL1#QF@$Kr)#jgCUgx0LN_y=l}o! literal 19 WcmdPX)7Jx|l*E!m{p7^tj8p(E0tH_H