Extract combat reach helpers and document movement rewrite progress

Move shared combat reach checks out of swing handlers and update melee, range, and magic handlers to delegate to the new reach boundary. Add focused combat tests for large-target melee reach and planner behavior, and update the living combat movement rewrite notes with completed progress and next steps.
This commit is contained in:
dam 2026-04-29 00:06:36 +03:00
parent a816e3378e
commit 71e8812124
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
8 changed files with 351 additions and 147 deletions

View file

@ -0,0 +1,99 @@
package core.game.node.entity.combat
import core.game.node.entity.Entity
import core.game.world.map.Location
import core.game.world.map.Point
import core.game.world.map.RegionManager
/**
* Plans combat-specific chase targets without mutating walking queues.
*/
object CombatMovementPlanner {
data class Plan(
val targetLocation: Location,
val attackTile: Location?
)
@JvmStatic
fun plan(attacker: Entity, target: Entity): Plan {
val targetLocation = predictedTargetLocation(target)
return Plan(
targetLocation = targetLocation,
attackTile = chooseTargetBorderTile(attacker, target, targetLocation)
)
}
@JvmStatic
fun predictedTargetLocation(target: Entity): Location {
return predictTargetLocations(target).lastOrNull() ?: target.location
}
@JvmStatic
fun predictTargetLocations(target: Entity): List<Location> {
return predictTargetLocations(target, movementStepsThisTick(target))
}
@JvmStatic
fun predictTargetLocations(target: Entity, maxSteps: Int): List<Location> {
if (maxSteps <= 0) {
return emptyList()
}
val queued = movementPoints(target)
if (queued.isEmpty()) {
return emptyList()
}
val steps = if (queued.first().isRunDisabled) 1 else maxSteps.coerceAtMost(2)
return queued.take(steps).map { Location.create(it.x, it.y, target.location.z) }
}
@JvmStatic
fun movementStepsThisTick(target: Entity): Int {
val queued = movementPoints(target)
if (queued.isEmpty()) {
return 0
}
return if (target.walkingQueue.isRunningBoth && !queued.first().isRunDisabled && queued.size > 1) {
2
} else {
1
}
}
@JvmStatic
fun chooseTargetBorderTile(attacker: Entity, target: Entity): Location? {
return chooseTargetBorderTile(attacker, target, predictedTargetLocation(target))
}
@JvmStatic
fun chooseTargetBorderTile(attacker: Entity, target: Entity, targetLocation: Location): Location? {
val candidates = borderTiles(target, targetLocation, attacker.size())
val reachable = candidates.filter { RegionManager.isTeleportPermitted(it) }
return (reachable.ifEmpty { candidates }).minWithOrNull(
compareBy<Location> { it.getDistance(attacker.location) }
.thenBy { it.x }
.thenBy { it.y }
)
}
@JvmStatic
fun borderTiles(target: Entity, targetLocation: Location, attackerSize: Int): List<Location> {
val targetSize = target.size()
val border = LinkedHashSet<Location>()
val plane = targetLocation.z
val minOffset = -attackerSize + 1
val maxOffset = targetSize - 1
for (offset in minOffset..maxOffset) {
border.add(Location.create(targetLocation.x - attackerSize, targetLocation.y + offset, plane))
border.add(Location.create(targetLocation.x + targetSize, targetLocation.y + offset, plane))
border.add(Location.create(targetLocation.x + offset, targetLocation.y - attackerSize, plane))
border.add(Location.create(targetLocation.x + offset, targetLocation.y + targetSize, plane))
}
return border.toList()
}
private fun movementPoints(target: Entity): List<Point> {
return target.walkingQueue.queue.filter { it.direction != null }
}
}

View file

@ -0,0 +1,171 @@
package core.game.node.entity.combat
import core.game.container.impl.EquipmentContainer
import core.game.node.entity.Entity
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.world.map.Direction
import core.game.world.map.Location
import core.game.world.map.RegionManager.getClippingFlag
import core.game.world.map.path.Pathfinder
import core.game.world.map.path.Pathfinder.PREVENT_EAST
import core.game.world.map.path.Pathfinder.PREVENT_NORTH
import core.game.world.map.path.Pathfinder.PREVENT_NORTHEAST
import core.game.world.map.path.Pathfinder.PREVENT_NORTHWEST
import core.game.world.map.path.Pathfinder.PREVENT_SOUTH
import core.game.world.map.path.Pathfinder.PREVENT_SOUTHEAST
import core.game.world.map.path.Pathfinder.PREVENT_SOUTHWEST
import core.game.world.map.path.Pathfinder.PREVENT_WEST
/**
* Shared combat reach calculations.
*/
object CombatReach {
@JvmStatic
fun isUsingHalberd(entity: Entity): Boolean {
if (entity is Player) {
val weapon = entity.equipment[EquipmentContainer.SLOT_WEAPON]
if (weapon != null) {
return weapon.id in 3190..3204 || weapon.id == 6599
}
} else if (entity is NPC) {
return entity.id == 8612
}
return false
}
@JvmStatic
fun meleeDistance(entity: Entity): Int {
return if (isUsingHalberd(entity)) 2 else 1
}
@JvmStatic
fun canMelee(entity: Entity, victim: Entity?, distance: Int): Boolean {
val e = entity.location
if (victim == null) {
return false
}
if (entity.id == 7135 && entity.location.withinDistance(victim.location, 2)) {
return true
}
val x = victim.location.x
val y = victim.location.y
val size = entity.size()
if (distance == 1) {
for (i in 0 until size) {
if (Pathfinder.isStandingIn(e.x - 1, e.y + i, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
if (Pathfinder.isStandingIn(e.x + size, e.y + i, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
if (Pathfinder.isStandingIn(e.x + i, e.y - 1, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
if (Pathfinder.isStandingIn(e.x + i, e.y + size, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
}
if (e == victim.location) {
return true
}
return victim.getSwingHandler(false).type == CombatStyle.MELEE &&
e.withinDistance(victim.location, 1) &&
victim.properties.combatPulse.getVictim() === entity &&
entity.index < victim.index
}
return entity.centerLocation.withinDistance(
victim.centerLocation,
distance + (size shr 1) + (victim.size() shr 1)
)
}
@JvmStatic
fun combatDistance(entity: Entity, victim: Entity, rawDistance: Int): Int {
var distance = rawDistance
if (entity is NPC && entity.definition.combatDistance > 0) {
distance = entity.definition.combatDistance
}
return (entity.size() shr 1) + (victim.size() shr 1) + distance
}
@JvmStatic
fun canReach(entity: Entity, victim: Entity, distance: Int): Boolean {
return victim.centerLocation.withinDistance(entity.centerLocation, distance)
}
@JvmStatic
fun canStepTowards(entity: Entity, victim: Entity): InteractionType {
val closestVictimTile = victim.getClosestOccupiedTile(entity.location)
val closestEntityTile = entity.getClosestOccupiedTile(closestVictimTile)
val dir = closestEntityTile.deriveDirection(closestVictimTile)
?: return InteractionType.STILL_INTERACT
var next = closestEntityTile
// A fixed-direction walk can pass beside an oblique target without converging, so
// limit the skipped gap to the number of steps available from the starting distance.
val maxSkipSteps = next.getDistance(closestVictimTile).toInt() + 1
for (i in 0 until maxSkipSteps) {
if (next.getDistance(closestVictimTile) <= 3) {
break
}
next = next.transform(dir)
}
if (next.getDistance(closestVictimTile) > 3) {
return InteractionType.STILL_INTERACT
}
var result = InteractionType.STILL_INTERACT
val maxIterations = next.getDistance(closestVictimTile).toInt()
for (i in 0 until maxIterations) {
next = next.transform(dir)
result = checkStepInterval(dir, next)
if (result == InteractionType.NO_INTERACT) {
break
}
}
return result
}
private fun checkStepInterval(dir: Direction, next: Location): InteractionType {
val components = next.getStepComponents(dir)
when (dir) {
Direction.NORTH -> if (getClippingFlag(next) and PREVENT_NORTH != 0) return InteractionType.NO_INTERACT
Direction.EAST -> if (getClippingFlag(next) and PREVENT_EAST != 0) return InteractionType.NO_INTERACT
Direction.SOUTH -> if (getClippingFlag(next) and PREVENT_SOUTH != 0) return InteractionType.NO_INTERACT
Direction.WEST -> if (getClippingFlag(next) and PREVENT_WEST != 0) return InteractionType.NO_INTERACT
Direction.NORTH_EAST -> {
if (getClippingFlag(components[0]) and PREVENT_EAST != 0 ||
getClippingFlag(components[1]) and PREVENT_NORTH != 0 ||
getClippingFlag(next) and PREVENT_NORTHEAST != 0
) return InteractionType.NO_INTERACT
}
Direction.NORTH_WEST -> {
if (getClippingFlag(components[0]) and PREVENT_WEST != 0 ||
getClippingFlag(components[1]) and PREVENT_NORTH != 0 ||
getClippingFlag(next) and PREVENT_NORTHWEST != 0
) return InteractionType.NO_INTERACT
}
Direction.SOUTH_EAST -> {
if (getClippingFlag(components[0]) and PREVENT_EAST != 0 ||
getClippingFlag(components[1]) and PREVENT_SOUTH != 0 ||
getClippingFlag(next) and PREVENT_SOUTHEAST != 0
) return InteractionType.NO_INTERACT
}
Direction.SOUTH_WEST -> {
if (getClippingFlag(components[0]) and PREVENT_WEST != 0 ||
getClippingFlag(components[1]) and PREVENT_SOUTH != 0 ||
getClippingFlag(next) and PREVENT_SOUTHWEST != 0
) return InteractionType.NO_INTERACT
}
}
return InteractionType.STILL_INTERACT
}
}

View file

@ -13,11 +13,7 @@ import core.game.node.entity.skill.Skills
import content.global.skill.summoning.familiar.Familiar
import core.api.log
import core.api.playGlobalAudio
import core.game.world.map.Direction
import core.game.world.map.Location
import core.game.world.map.RegionManager
import core.game.world.map.RegionManager.getClippingFlag
import core.game.world.map.path.Pathfinder
import core.game.world.map.path.Pathfinder.*
import core.game.world.update.flag.context.Animation
import core.tools.RandomFunction
@ -248,77 +244,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
}
protected fun canStepTowards(entity: Entity, victim: Entity): InteractionType {
val closestVictimTile = victim.getClosestOccupiedTile(entity.location)
val closestEntityTile = entity.getClosestOccupiedTile(closestVictimTile)
val dir = closestEntityTile.deriveDirection(closestVictimTile)
?: return InteractionType.STILL_INTERACT //if we cannot derive a direction, it's because both tiles are the same, so hand off control to the main logic which already handles this case
var next = closestEntityTile
//Skip the initial gap in distance if it exists, because standard pathfinding would already stop us before this point if something was between us and the NPC or vice versa.
//A fixed-direction walk can only arrive within its starting distance in steps; on oblique approaches it
//passes beside the target and never converges, so the walk is hard-capped at that many steps.
val maxSkipSteps = next.getDistance(closestVictimTile).toInt() + 1
for (i in 0 until maxSkipSteps) {
if (next.getDistance(closestVictimTile) <= 3) break
next = next.transform(dir)
}
if (next.getDistance(closestVictimTile) > 3) return InteractionType.STILL_INTERACT //never converged (oblique approach), so defer to the range checks instead
var result: InteractionType = InteractionType.STILL_INTERACT
val maxIterations = next.getDistance(closestVictimTile).toInt()
for (i in 0 until maxIterations) { //step towards the target tile, checking if anything would obstruct us on the way, and immediately breaking + returning if it does.
next = next.transform(dir)
result = checkStepInterval(dir, next)
if (result == InteractionType.NO_INTERACT) break
}
return result
}
private fun checkStepInterval(
dir: Direction,
next: Location
): InteractionType {
val components = next.getStepComponents(dir)
when (dir) {
Direction.NORTH -> if (getClippingFlag(next) and PREVENT_NORTH != 0) return InteractionType.NO_INTERACT
Direction.EAST -> if (getClippingFlag(next) and PREVENT_EAST != 0) return InteractionType.NO_INTERACT
Direction.SOUTH -> if (getClippingFlag(next) and PREVENT_SOUTH != 0) return InteractionType.NO_INTERACT
Direction.WEST -> if (getClippingFlag(next) and PREVENT_WEST != 0) return InteractionType.NO_INTERACT
Direction.NORTH_EAST -> {
if (getClippingFlag(components[0]) and PREVENT_EAST != 0
|| getClippingFlag(components[1]) and PREVENT_NORTH != 0
|| getClippingFlag(next) and PREVENT_NORTHEAST != 0
) return InteractionType.NO_INTERACT
}
Direction.NORTH_WEST -> {
if (getClippingFlag(components[0]) and PREVENT_WEST != 0
|| getClippingFlag(components[1]) and PREVENT_NORTH != 0
|| getClippingFlag(next) and PREVENT_NORTHWEST != 0
) return InteractionType.NO_INTERACT
}
Direction.SOUTH_EAST -> {
if (getClippingFlag(components[0]) and PREVENT_EAST != 0
|| getClippingFlag(components[1]) and PREVENT_SOUTH != 0
|| getClippingFlag(next) and PREVENT_SOUTHEAST != 0
) return InteractionType.NO_INTERACT
}
Direction.SOUTH_WEST -> {
if (getClippingFlag(components[0]) and PREVENT_WEST != 0
|| getClippingFlag(components[1]) and PREVENT_SOUTH != 0
|| getClippingFlag(next) and PREVENT_SOUTHWEST != 0
) return InteractionType.NO_INTERACT
}
}
return InteractionType.STILL_INTERACT
return CombatReach.canStepTowards(entity, victim)
}
/**
@ -384,13 +310,7 @@ abstract class CombatSwingHandler(var type: CombatStyle?) {
* @return The actual distance used for combat.
*/
open fun getCombatDistance(e: Entity, v: Entity, rawDistance: Int): Int {
var distance = rawDistance
if (e is NPC) {
if (e.definition.combatDistance > 0) {
distance = e.definition.combatDistance
}
}
return (e.size() shr 1) + (v.size() shr 1) + distance
return CombatReach.combatDistance(e, v, rawDistance)
}
/**

View file

@ -26,9 +26,9 @@ open class MagicSwingHandler (vararg flags: SwingHandlerFlag)
}
var distance = 10
var type = InteractionType.STILL_INTERACT
var goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, distance))
var goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, distance))
if (victim.walkingQueue.isMoving && !goodRange) {
goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, ++distance))
goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, ++distance))
type = InteractionType.MOVE_INTERACT
}
if (goodRange && isAttackable(entity, victim) != InteractionType.NO_INTERACT) {

View file

@ -7,7 +7,6 @@ import content.global.skill.slayer.Tasks
import content.global.skill.summoning.SummoningPouch
import core.api.*
import core.api.EquipmentSlot
import core.game.container.impl.EquipmentContainer
import core.game.node.entity.Entity
import core.game.node.entity.combat.equipment.ArmourSet
import core.game.node.entity.combat.equipment.Weapon
@ -15,10 +14,8 @@ import core.game.node.entity.combat.equipment.WeaponInterface
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.node.entity.skill.Skills
import core.game.world.map.path.Pathfinder
import core.tools.RandomFunction
import org.rs09.consts.Items
import kotlin.math.ceil
import kotlin.math.floor
/**
@ -33,15 +30,15 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
: CombatSwingHandler(CombatStyle.MELEE, *flags) {
override fun canSwing(entity : Entity, victim : Entity) : InteractionType? {
//Credits wolfenzi, https://www.rune-server.ee/2009scape-development/rs2-server/snippets/608720-arios-hybridding-improve.html
var distance = if (usingHalberd(entity)) 2 else 1
var distance = CombatReach.meleeDistance(entity)
var type = InteractionType.STILL_INTERACT
var goodRange = canMelee(entity, victim, distance)
var goodRange = CombatReach.canMelee(entity, victim, distance)
if (!goodRange && victim.properties.combatPulse.getVictim() !== entity && victim.walkingQueue.isMoving && entity.size() == 1) {
type = InteractionType.MOVE_INTERACT
distance += if (entity.walkingQueue.isRunningBoth) 2 else 1
goodRange = canMelee(entity, victim, distance)
goodRange = CombatReach.canMelee(entity, victim, distance)
}
if (!isProjectileClipped(entity, victim, !usingHalberd(entity))) {
if (!isProjectileClipped(entity, victim, !CombatReach.isUsingHalberd(entity))) {
return InteractionType.NO_INTERACT
}
val isRunning = entity.walkingQueue.runDir != -1
@ -311,23 +308,6 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
}
companion object {
/**
* Checks if the entity is using a halberd.
* @param entity The entity.
* @return `True` if so.
*/
private fun usingHalberd(entity: Entity): Boolean {
if (entity is Player) {
val weapon = entity.equipment[EquipmentContainer.SLOT_WEAPON]
if (weapon != null) {
return weapon.id in 3190..3204 || weapon.id == 6599
}
} else if (entity is NPC) {
return entity.id == 8612
}
return false
}
/**
* Checks if the entity can execute a melee swing from its current location.
* @param entity The attacking entity.
@ -335,37 +315,7 @@ open class MeleeSwingHandler (vararg flags: SwingHandlerFlag)
* @return `True` if so.
*/
fun canMelee(entity: Entity, victim: Entity?, distance: Int): Boolean {
val e = entity.location
if (victim == null) {
return false
}
if (entity.id == 7135 && entity.location.withinDistance(victim.location, 2)) {
return true
}
val x = victim.location.x
val y = victim.location.y
val size = entity.size()
if (distance == 1) {
for (i in 0 until size) {
if (Pathfinder.isStandingIn(e.x - 1, e.y + i, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
if (Pathfinder.isStandingIn(e.x + size, e.y + i, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
if (Pathfinder.isStandingIn(e.x + i, e.y - 1, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
if (Pathfinder.isStandingIn(e.x + i, e.y + size, 1, 1, x, y, victim.size(), victim.size())) {
return true
}
}
if (e == victim.location) {
return true
}
return victim.getSwingHandler(false).type == CombatStyle.MELEE && e.withinDistance(victim.location, 1) && victim.properties.combatPulse.getVictim() === entity && entity.index < victim.index
}
return entity.centerLocation.withinDistance(victim.centerLocation, distance + (size shr 1) + (victim.size() shr 1))
return CombatReach.canMelee(entity, victim, distance)
}
}
}

View file

@ -53,10 +53,10 @@ open class RangeSwingHandler (vararg flags: SwingHandlerFlag) : CombatSwingHandl
distance = 10
}
}
var goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, distance))
var goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, distance))
var type = InteractionType.STILL_INTERACT
if (victim.walkingQueue.isMoving && !goodRange) {
goodRange = victim.centerLocation.withinDistance(entity.centerLocation, getCombatDistance(entity, victim, ++distance))
goodRange = CombatReach.canReach(entity, victim, getCombatDistance(entity, victim, ++distance))
type = InteractionType.MOVE_INTERACT
}
if (goodRange && super.canSwing(entity, victim) != InteractionType.NO_INTERACT) {

View file

@ -2,15 +2,19 @@ package content
import TestUtils
import content.global.handlers.item.equipment.special.ChinchompaSwingHandler
import core.ServerConstants
import core.api.EquipmentSlot
import core.game.container.impl.EquipmentContainer.updateBonuses
import core.game.interaction.IntType
import core.game.interaction.InteractionListeners
import core.game.node.entity.combat.CombatMovementPlanner
import core.game.node.entity.combat.CombatReach
import core.game.node.entity.combat.MagicSwingHandler
import core.game.node.entity.combat.MeleeSwingHandler
import core.game.node.entity.combat.RangeSwingHandler
import core.game.node.entity.combat.SwingHandlerFlag
import core.game.node.entity.combat.equipment.WeaponInterface
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.link.prayer.PrayerType
import core.game.node.entity.skill.Skills
import core.game.node.item.Item
@ -135,4 +139,48 @@ class CombatTests {
Assertions.assertEquals(damageBaseline, handler.calculateHit(p, p, 1.0))
}
}
}
@Test
fun combatReachUsesOccupiedTilesForLargeMeleeTargets() {
TestUtils.getMockPlayer("combatReachLargeTarget").use { attacker ->
val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
val victim = NPC.create(100, origin.transform(1, 0, 0))
victim.setSize(2)
attacker.location = origin
Assertions.assertTrue(CombatReach.canMelee(attacker, victim, 1))
Assertions.assertTrue(MeleeSwingHandler.canMelee(attacker, victim, 1))
}
}
@Test
fun combatMovementPlannerPredictsRunningTargetSteps() {
TestUtils.getMockPlayer("combatPlannerRunner").use { target ->
val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
target.location = origin
target.walkingQueue.reset(true)
target.walkingQueue.addPath(origin.x + 3, origin.y)
Assertions.assertEquals(
listOf(origin.transform(1, 0, 0), origin.transform(2, 0, 0)),
CombatMovementPlanner.predictTargetLocations(target)
)
Assertions.assertEquals(origin.transform(2, 0, 0), CombatMovementPlanner.predictedTargetLocation(target))
}
}
@Test
fun combatMovementPlannerChoosesClosestTargetBorderTile() {
TestUtils.getMockPlayer("combatPlannerAttacker").use { attacker ->
val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0)
val victim = NPC.create(100, origin.transform(4, 0, 0))
victim.setSize(2)
attacker.location = origin
Assertions.assertEquals(
origin.transform(3, 0, 0),
CombatMovementPlanner.chooseTargetBorderTile(attacker, victim, victim.location)
)
}
}
}

View file

@ -91,10 +91,26 @@ Initial targets:
## Suggested Implementation Sequence
1. Extract a `CombatReach` utility from the swing handlers.
2. Add a `CombatMovementPlanner` that can choose target border tiles and predict
1. [x] Extract a `CombatReach` utility from the swing handlers.
2. [x] Add a `CombatMovementPlanner` that can choose target border tiles and predict
one or two target movement steps from `WalkingQueue`.
3. Add a combat movement intent phase that resolves step conflicts
3. [ ] Add a combat movement intent phase that resolves step conflicts
deterministically.
4. Remove the private `MovementPulse` from `CombatPulse`.
5. Enable the pending tests one scenario at a time.
4. [ ] Remove the private `MovementPulse` from `CombatPulse`.
5. [ ] Enable the pending tests one scenario at a time.
## Progress Log
- 2026-04-28: Extracted `CombatReach` in `core.game.node.entity.combat`.
Melee occupied-tile reach, halberd reach, generic center-distance reach,
NPC combat-distance overrides, and straight-line step validation now live
behind that utility. Existing swing handlers delegate to it, while
`MeleeSwingHandler.canMelee(...)` remains as a compatibility wrapper for
current callers. Added a focused regression in `CombatTests` for large-target
melee occupied-tile reach.
- 2026-04-28: Added a standalone `CombatMovementPlanner` that reads
`WalkingQueue` without mutating it, predicts the target's next one or two
movement locations using run state and run-disabled path points, and chooses
the closest valid border tile around the predicted occupied area. The planner
is not yet wired into `CombatPulse`; the next step is the deterministic combat
movement intent phase.