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.