Lazy ranged path batching, projectile edge case fix, Bork fix

This commit is contained in:
dam 2026-07-17 15:00:27 +03:00
parent 9331e40eb4
commit b1231c749f
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
8 changed files with 326 additions and 53 deletions

View file

@ -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<MovementDestination> {
): Sequence<MovementDestination> {
if (attacker is NPC && pathfinder === Pathfinder.DUMB) {
val destinations = ArrayList<MovementDestination>(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<MovementDestination>(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<MovementDestination>(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<MovementDestination> {
): Sequence<MovementDestination> {
val range = playerAttackRange(attacker)
if (range <= CombatReach.meleeDistance(attacker)) {
return emptyList()
return emptySequence()
}
val attackTiles = attackRangeTiles(attacker, target, targetLocation, range, trace)
val destinations = ArrayList<MovementDestination>(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<Location> {
): Sequence<Location> {
val tiles = ArrayList<Location>()
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<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,
)
) {
continue
}
attackTiles.add(tile)
if (attackTiles.size >= MAX_RANGED_APPROACH_CANDIDATES) {
break
return sequence {
var nextTile = 0
while (nextTile < tiles.size) {
val batch = ArrayList<Location>(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(),

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Pair<Location, Location>>()
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<Location>().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<Location> {
val targetLocation = target.location
val rangeSquared = range * range
val candidates = ArrayList<Location>()
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<Location> {
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<Location>): List<Pair<Location, Location>> {
val edges = ArrayList<Pair<Location, Location>>()
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

View file

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

Binary file not shown.