Fixed dumb NPC melee safespot pathing

Default melee NPCs now use constrained target-facing combat tiles instead of scanning every border tile, preserving safespots around obstacles.

Diagonal chases may use the two corner-adjacent sides so NPCs can step into range when a direct-ish tile is open, without routing around blocked corners.

Added combat movement regressions for static safespots, one-tile obstacle chases, and diagonal corner cases.
This commit is contained in:
dam 2026-04-30 16:20:25 +03:00
parent 64b6d858a0
commit 9f8faa3547
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
3 changed files with 249 additions and 17 deletions

View file

@ -3,11 +3,12 @@ package core.game.node.entity.combat
import core.game.node.entity.Entity
import core.game.node.entity.npc.NPC
import core.game.node.entity.player.Player
import core.game.world.repository.Repository
import core.game.world.map.Direction
import core.game.world.map.Location
import core.game.world.map.Point
import core.game.world.map.path.Path
import core.game.world.map.path.Pathfinder
import core.game.world.repository.Repository
import java.util.LinkedHashMap
/**
@ -16,6 +17,11 @@ import java.util.LinkedHashMap
*/
object CombatMovementIntents {
private data class Intent(val attacker: Entity, val target: Entity)
private data class MovementDestination(
val location: Location,
val pathfinder: Pathfinder,
val allowPartialPath: Boolean
)
private data class CandidatePath(val steps: List<Point>, val projectedLocation: Location)
private val intents = LinkedHashMap<Entity, Intent>()
@ -99,8 +105,7 @@ object CombatMovementIntents {
return
}
val targetLocation = targetLocationFor(attacker, target)
val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation)
val candidates = movementDestinationsFor(attacker, target)
var blockedByReservation = false
for (candidate in candidates) {
val candidatePath = pathTo(attacker, candidate) ?: continue
@ -115,7 +120,7 @@ object CombatMovementIntents {
reservedTiles.addAll(projectedTiles)
return
}
if (!blockedByReservation && CombatMovementPlanner.movementStepsThisTick(target) == 0) {
if (!blockedByReservation && shouldStopUnreachableCombat(attacker, target)) {
stopUnreachableCombat(attacker)
}
}
@ -137,13 +142,76 @@ object CombatMovementIntents {
return attacker !is NPC || !attacker.isNeverWalks
}
private fun pathTo(attacker: Entity, destination: Location): CandidatePath? {
if (attacker.location == destination) {
private fun movementDestinationsFor(attacker: Entity, target: Entity): List<MovementDestination> {
val pathfinder = pathfinderFor(attacker)
if (attacker is NPC && pathfinder === Pathfinder.DUMB) {
return dumbNpcAttackDestinations(attacker, target).map {
MovementDestination(it, pathfinder, allowPartialPath = true)
}
}
val targetLocation = targetLocationFor(attacker, target)
return CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation).map {
MovementDestination(it, pathfinder, allowPartialPath = false)
}
}
private fun dumbNpcAttackDestinations(attacker: NPC, target: Entity): List<Location> {
val directions = LinkedHashSet<Direction>()
directions.add(Direction.getLogicalDirection(target.centerLocation, attacker.centerLocation))
if (attacker.centerLocation.x < target.centerLocation.x) {
directions.add(Direction.WEST)
} else if (attacker.centerLocation.x > target.centerLocation.x) {
directions.add(Direction.EAST)
}
if (attacker.centerLocation.y < target.centerLocation.y) {
directions.add(Direction.SOUTH)
} else if (attacker.centerLocation.y > target.centerLocation.y) {
directions.add(Direction.NORTH)
}
return directions.map { dumbNpcAttackDestination(attacker, target, it) }
}
private fun dumbNpcAttackDestination(attacker: NPC, target: Entity, direction: Direction): Location {
val targetLocation = target.location
val minAlignedX = targetLocation.x - attacker.size() + 1
val maxAlignedX = targetLocation.x + target.size() - 1
val minAlignedY = targetLocation.y - attacker.size() + 1
val maxAlignedY = targetLocation.y + target.size() - 1
return when (direction) {
Direction.WEST -> Location.create(
targetLocation.x - attacker.size(),
attacker.location.y.coerceIn(minAlignedY, maxAlignedY),
targetLocation.z
)
Direction.EAST -> Location.create(
targetLocation.x + target.size(),
attacker.location.y.coerceIn(minAlignedY, maxAlignedY),
targetLocation.z
)
Direction.SOUTH -> Location.create(
attacker.location.x.coerceIn(minAlignedX, maxAlignedX),
targetLocation.y - attacker.size(),
targetLocation.z
)
else -> Location.create(
attacker.location.x.coerceIn(minAlignedX, maxAlignedX),
targetLocation.y + target.size(),
targetLocation.z
)
}
}
private fun pathTo(attacker: Entity, destination: MovementDestination): CandidatePath? {
if (attacker.location == destination.location) {
return null
}
val path = Pathfinder.find(attacker, destination, false, pathfinderFor(attacker))
if (!path.reaches(destination)) {
val path = Pathfinder.find(attacker, destination.location, destination.allowPartialPath, destination.pathfinder)
if (!path.reaches(destination.location) && (!destination.allowPartialPath || path.points.isEmpty())) {
return null
}
val steps = immediateMovementSteps(attacker, path)
@ -197,6 +265,10 @@ object CombatMovementIntents {
return attacker is Player && attacker.walkingQueue.isRunningBoth && attacker.settings.runEnergy >= 1.0
}
private fun shouldStopUnreachableCombat(attacker: Entity, target: Entity): Boolean {
return attacker is Player && CombatMovementPlanner.movementStepsThisTick(target) == 0
}
private fun stopUnreachableCombat(attacker: Entity) {
attacker.properties.combatPulse.stop()
attacker.walkingQueue.reset()

View file

@ -463,6 +463,155 @@ class CombatMovementTests {
}
}
@Test
fun dumbMeleeNpcShouldNotRouteAroundSafespotObstacle() {
TestUtils.getMockPlayer("combat_safespot_target").use { player ->
val origin = arenaOrigin()
val blockedTiles = listOf(
origin.transform(1, 0, 0),
origin.transform(0, -1, 0)
)
place(player, origin.transform(4, 0, 0))
val npc = NPC.create(100, origin)
npc.init()
try {
configureMelee(npc)
blockMovementTiles(blockedTiles)
npc.attack(player)
CombatMovementIntents.clear()
CombatMovementIntents.request(npc, player)
CombatMovementIntents.resolve()
npc.walkingQueue.update()
assertEquals(
origin,
npc.location,
"Default dumb NPC combat pathing should not choose alternate border tiles to route around safespots."
)
assertTrue(npc.properties.combatPulse.isAttacking)
} finally {
unblockMovementTiles(blockedTiles)
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun dumbMeleeNpcShouldUseOpenCornerSideWhenDiagonallyFacingTarget() {
TestUtils.getMockPlayer("combat_diagonal_corner_target").use { player ->
val origin = arenaOrigin()
val northSide = origin.transform(0, 1, 0)
val westSide = origin.transform(-1, 0, 0)
place(player, origin)
val npc = NPC.create(100, origin.transform(-1, 1, 0))
npc.init()
try {
configureMelee(npc)
blockMovementTiles(listOf(northSide))
npc.attack(player)
CombatMovementIntents.clear()
CombatMovementIntents.request(npc, player)
CombatMovementIntents.resolve()
npc.walkingQueue.update()
assertEquals(
westSide,
npc.location,
"A diagonal dumb NPC should try the other corner-adjacent side when its preferred side is blocked."
)
assertTrue(meleeReach(npc, player))
assertTrue(npc.properties.combatPulse.isAttacking)
} finally {
unblockMovementTiles(listOf(northSide))
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun dumbMeleeNpcShouldNotRouteAroundWhenBothCornerSidesAreBlocked() {
TestUtils.getMockPlayer("combat_blocked_diagonal_corner_target").use { player ->
val origin = arenaOrigin()
val blockedTiles = listOf(
origin.transform(0, 1, 0),
origin.transform(-1, 0, 0)
)
val npcStart = origin.transform(-1, 1, 0)
place(player, origin)
val npc = NPC.create(100, npcStart)
npc.init()
try {
configureMelee(npc)
blockMovementTiles(blockedTiles)
npc.attack(player)
CombatMovementIntents.clear()
CombatMovementIntents.request(npc, player)
CombatMovementIntents.resolve()
npc.walkingQueue.update()
assertEquals(
npcStart,
npc.location,
"A diagonal dumb NPC should not rotate to the far side when both corner-adjacent attack tiles are blocked."
)
assertTrue(npc.properties.combatPulse.isAttacking)
} finally {
unblockMovementTiles(blockedTiles)
npc.clear()
CombatMovementIntents.clear()
}
}
}
@Test
fun dumbMeleeNpcShouldPrioritizeDirectSideWhenTargetRunsBehindObstacle() {
TestUtils.getMockPlayer("combat_running_safespot_target").use { player ->
val origin = arenaOrigin()
val obstacle = origin.transform(1, 0, 0)
val playerEnd = origin.transform(2, 0, 0)
val playerPath = listOf(
origin.transform(0, 1, 0),
origin.transform(1, 1, 0),
origin.transform(2, 1, 0),
playerEnd
)
place(player, origin)
enableRun(player)
val npc = NPC.create(100, origin.transform(-1, 0, 0))
npc.init()
try {
configureMelee(npc)
blockMovementTiles(listOf(obstacle))
npc.attack(player)
queueRunPath(player, playerPath)
TestUtils.advanceTicks(6, false)
assertEquals(playerEnd, player.location)
assertEquals(
origin,
npc.location,
"Default dumb NPCs should pursue the target-facing side, then stall when that side is blocked."
)
assertFalse(meleeReach(npc, player))
assertTrue(npc.properties.combatPulse.isAttacking)
} finally {
unblockMovementTiles(listOf(obstacle))
npc.clear()
CombatMovementIntents.clear()
}
}
}
private fun arenaOrigin(): Location {
return Location.create(3200, 3600, 0)
}
@ -478,6 +627,13 @@ class CombatMovementTests {
entity.walkingQueue.addPath(destination.x, destination.y)
}
private fun queueRunPath(entity: Entity, path: List<Location>) {
entity.walkingQueue.reset(true)
for (location in path) {
entity.walkingQueue.addPath(location.x, location.y)
}
}
private fun configureMelee(entity: Entity) {
entity.properties.attackStyle = WeaponInterface.AttackStyle(
WeaponInterface.STYLE_AGGRESSIVE,

View file

@ -128,10 +128,10 @@ Initial targets:
still rely on their existing swing-distance checks to stop movement once they
are in range; the shared intent resolver owns the chase path.
- 2026-04-29: Enabled the first pending combat movement scenario:
`meleeAttackerShouldMirrorRunningVictimAndKeepAttackPressure`. The fixture now
uses an open wilderness arena and explicitly enables wilderness PvP for player
versus player movement tests so combat pulses do not stop at attackability
checks before movement can be exercised.
`meleeAttackerShouldMirrorRunningVictimWhenRunEnabledAndKeepAttackPressure`.
The fixture now uses an open wilderness arena and explicitly enables
wilderness PvP for player versus player movement tests so combat pulses do not
stop at attackability checks before movement can be exercised.
- 2026-04-29: Enabled the remaining pending combat movement scenarios:
mutual melee approach, player versus moving melee NPC chase, movement-locked
in-range melee, and large-target occupied-tile melee reach. The focused
@ -139,11 +139,9 @@ Initial targets:
disabled tests.
- 2026-04-29: Fixed the remaining melee chase gap against running targets. A
melee attacker now submits chase pressure even while currently in melee range
if the target's queued movement would leave that range, and combat intent
resolution force-runs the attacker's chase path when the target's next
movement tick is a run. Added a regression covering the active pulse/intent
handoff so a player attacking a running-away target stays adjacent after both
walking queues advance.
if the target's queued movement would leave that range. Added a regression
covering the active pulse/intent handoff so a run-enabled player attacking a
running-away target stays adjacent after both walking queues advance.
- 2026-04-29: Fixed the live PvP run-click ordering gap. The previous pressure
check only happened inside the attacker's `CombatPulse`, so a victim's normal
map-click `MovementPulse` could be queued later in the same pulser batch; the
@ -153,3 +151,9 @@ Initial targets:
tick listeners, then resolves intents before entity walking queues step. Added
a regression that uses the real `WorldspaceWalk` packet path with both players
run-enabled and wielding dragon scimitars.
- 2026-04-30: Corrected combat intent movement so PvP melee chase respects the
attacker's own run state. The resolver now clamps target prediction to the
attacker's current walk/run capacity, treats zero run energy as walking, and
no longer forces `WalkingQueue.reset(true)` when the attacker has run toggled
off. If a moving target temporarily blocks an exact walking path, combat waits
for the next tick instead of stopping as unreachable.