From 71e881212423dc9cfdb8d0ba98e0ca2680c83add Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Wed, 29 Apr 2026 00:06:36 +0300 Subject: [PATCH] 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. --- .../entity/combat/CombatMovementPlanner.kt | 99 ++++++++++ .../game/node/entity/combat/CombatReach.kt | 171 ++++++++++++++++++ .../node/entity/combat/CombatSwingHandler.kt | 84 +-------- .../node/entity/combat/MagicSwingHandler.kt | 4 +- .../node/entity/combat/MeleeSwingHandler.kt | 60 +----- .../node/entity/combat/RangeSwingHandler.kt | 4 +- Server/src/test/kotlin/content/CombatTests.kt | 50 ++++- docs/combat-movement-rewrite.md | 26 ++- 8 files changed, 351 insertions(+), 147 deletions(-) create mode 100644 Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt create mode 100644 Server/src/main/core/game/node/entity/combat/CombatReach.kt diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt new file mode 100644 index 000000000..e48927a96 --- /dev/null +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt @@ -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 { + return predictTargetLocations(target, movementStepsThisTick(target)) + } + + @JvmStatic + fun predictTargetLocations(target: Entity, maxSteps: Int): List { + 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 { it.getDistance(attacker.location) } + .thenBy { it.x } + .thenBy { it.y } + ) + } + + @JvmStatic + fun borderTiles(target: Entity, targetLocation: Location, attackerSize: Int): List { + val targetSize = target.size() + val border = LinkedHashSet() + 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 { + return target.walkingQueue.queue.filter { it.direction != null } + } +} diff --git a/Server/src/main/core/game/node/entity/combat/CombatReach.kt b/Server/src/main/core/game/node/entity/combat/CombatReach.kt new file mode 100644 index 000000000..8948ea498 --- /dev/null +++ b/Server/src/main/core/game/node/entity/combat/CombatReach.kt @@ -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 + } +} 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 d4324b233..249d070cd 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatSwingHandler.kt @@ -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) } /** diff --git a/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt index 0062c20d9..8a366ed89 100644 --- a/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/MagicSwingHandler.kt @@ -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) { 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 7f9b668d8..d14fbdf2f 100644 --- a/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/MeleeSwingHandler.kt @@ -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) } } } diff --git a/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt b/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt index 5eeed5f21..f049145ec 100644 --- a/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt +++ b/Server/src/main/core/game/node/entity/combat/RangeSwingHandler.kt @@ -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) { diff --git a/Server/src/test/kotlin/content/CombatTests.kt b/Server/src/test/kotlin/content/CombatTests.kt index 762ed8406..244f43734 100644 --- a/Server/src/test/kotlin/content/CombatTests.kt +++ b/Server/src/test/kotlin/content/CombatTests.kt @@ -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)) } } -} \ No newline at end of file + + @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) + ) + } + } +} diff --git a/docs/combat-movement-rewrite.md b/docs/combat-movement-rewrite.md index 4d1427d2d..10c7dbedf 100644 --- a/docs/combat-movement-rewrite.md +++ b/docs/combat-movement-rewrite.md @@ -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.