From 5422d0ba2d23b3de001529a3f6ad2f49f7747e18 Mon Sep 17 00:00:00 2001 From: dam <27978131-real_damighty@users.noreply.gitlab.com> Date: Thu, 30 Apr 2026 22:49:34 +0300 Subject: [PATCH] Fixed melee combat pathing for overlapping actors Treat overlapping occupied tiles as out of melee reach so combat movement must find a real adjacent attack tile. Improve intent resolution to preserve queued steps that already enter attack range and account for same-tick projected movement, preventing reciprocal melee movement loops. Update dumb NPC overlap movement to consider all valid attack tiles instead of stalling on blocked preferred directions. Add regression coverage for player/NPC overlap cases, blocked-side fallback, and mutual melee movement. --- .../entity/combat/CombatMovementIntents.kt | 66 +++++- .../game/node/entity/combat/CombatReach.kt | 19 +- .../kotlin/content/CombatMovementTests.kt | 211 +++++++++++++++++- 3 files changed, 287 insertions(+), 9 deletions(-) diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt index a4c54fd1a..dab817cc2 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -71,8 +71,9 @@ object CombatMovementIntents { intents.clear() val reservedTiles = LinkedHashSet() + val projectedLocations = HashMap() for (intent in pending) { - resolve(intent, reservedTiles) + resolve(intent, reservedTiles, projectedLocations) } } @@ -95,7 +96,12 @@ object CombatMovementIntents { return false } val predictedTargetLocation = CombatMovementPlanner.predictedTargetLocation(target) - return attacker.location !in CombatMovementPlanner.candidateAttackTiles(attacker, target, predictedTargetLocation) + val attackTiles = CombatMovementPlanner.candidateAttackTiles(attacker, target, predictedTargetLocation) + if (attacker.location in attackTiles) { + return false + } + val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull() + return projectedAttackerLocation !in attackTiles } private fun requestActiveMeleePressure(attacker: Entity) { @@ -109,14 +115,42 @@ object CombatMovementIntents { } } - private fun resolve(intent: Intent, reservedTiles: MutableSet) { + private fun resolve( + intent: Intent, + reservedTiles: MutableSet, + projectedLocations: MutableMap + ) { val attacker = intent.attacker val target = intent.target if (!canResolve(attacker, target)) { return } + val projectedTargetLocation = projectedLocations[target] + if (projectedTargetLocation != null && canAttackFrom(attacker, target, attacker.location, projectedTargetLocation)) { + attacker.walkingQueue.reset() + attacker.face(target) + projectedLocations[attacker] = attacker.location + reservedTiles.addAll(occupiedTiles(attacker, attacker.location)) + return + } + val candidates = movementDestinationsFor(attacker, target) + if (candidates.any { it.location == attacker.location }) { + attacker.walkingQueue.reset() + attacker.face(target) + projectedLocations[attacker] = attacker.location + reservedTiles.addAll(occupiedTiles(attacker, attacker.location)) + return + } + val projectedAttackerLocation = CombatMovementPlanner.predictTargetLocations(attacker).lastOrNull() + if (projectedAttackerLocation != null && candidates.any { it.location == projectedAttackerLocation }) { + attacker.face(target) + projectedLocations[attacker] = projectedAttackerLocation + reservedTiles.addAll(occupiedTiles(attacker, projectedAttackerLocation)) + return + } + var blockedByReservation = false for (candidate in candidates) { val candidatePath = pathTo(attacker, candidate) ?: continue @@ -128,6 +162,7 @@ object CombatMovementIntents { walkPath(attacker, candidatePath) attacker.face(target) + projectedLocations[attacker] = candidatePath.projectedLocation reservedTiles.addAll(projectedTiles) return } @@ -157,10 +192,22 @@ object CombatMovementIntents { return attacker !is NPC || !attacker.isNeverWalks } + private fun canAttackFrom(attacker: Entity, target: Entity, attackerLocation: Location, targetLocation: Location): Boolean { + if (attackerLocation.z != targetLocation.z) { + return false + } + return attackerLocation in CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocation) + } + private fun movementDestinationsFor(attacker: Entity, target: Entity): List { val pathfinder = pathfinderFor(attacker) if (attacker is NPC && pathfinder === Pathfinder.DUMB) { - return dumbNpcAttackDestinations(attacker, target).map { + val destinations = if (occupiedTilesOverlap(attacker, target)) { + CombatMovementPlanner.candidateAttackTiles(attacker, target, targetLocationFor(attacker, target)) + } else { + dumbNpcAttackDestinations(attacker, target) + } + return destinations.map { MovementDestination(it, pathfinder, allowPartialPath = true) } } @@ -170,7 +217,9 @@ object CombatMovementIntents { MovementDestination(it, pathfinder, allowPartialPath = false) } if (attacker is Player) { - val targetFallback = if (shouldAllowPartialTargetPath(attacker, target, targetLocation)) { + val targetFallback = if (!occupiedTilesOverlap(attacker, target) && + shouldAllowPartialTargetPath(attacker, target, targetLocation) + ) { listOf(MovementDestination(target, pathfinder, allowPartialPath = true)) } else { emptyList() @@ -435,6 +484,13 @@ object CombatMovementIntents { return tiles } + private fun occupiedTilesOverlap(first: Entity, second: Entity): Boolean { + return first.location.x < second.location.x + second.size() && + first.location.x + first.size() > second.location.x && + first.location.y < second.location.y + second.size() && + first.location.y + first.size() > second.location.y + } + private fun pathfinderFor(attacker: Entity): Pathfinder { return when (attacker) { is Player -> Pathfinder.SMART diff --git a/Server/src/main/core/game/node/entity/combat/CombatReach.kt b/Server/src/main/core/game/node/entity/combat/CombatReach.kt index 8948ea498..cd74c25f8 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatReach.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatReach.kt @@ -48,6 +48,9 @@ object CombatReach { if (entity.id == 7135 && entity.location.withinDistance(victim.location, 2)) { return true } + if (occupiedAreasOverlap(entity, victim)) { + return false + } val x = victim.location.x val y = victim.location.y val size = entity.size() @@ -66,9 +69,6 @@ object CombatReach { 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 && @@ -80,6 +80,19 @@ object CombatReach { ) } + private fun occupiedAreasOverlap(first: Entity, second: Entity): Boolean { + return Pathfinder.isStandingIn( + first.location.x, + first.location.y, + first.size(), + first.size(), + second.location.x, + second.location.y, + second.size(), + second.size() + ) + } + @JvmStatic fun combatDistance(entity: Entity, victim: Entity, rawDistance: Int): Int { var distance = rawDistance diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index 6a90fceec..239a7ecb0 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -7,6 +7,7 @@ import core.game.global.action.DoorActionHandler import core.game.node.Node import core.game.node.entity.Entity import core.game.node.entity.combat.BattleState +import core.game.node.entity.combat.CombatReach import core.game.node.entity.combat.CombatSwingHandler import core.game.node.entity.combat.CombatMovementIntents import core.game.node.entity.combat.CombatMovementPlanner @@ -219,6 +220,209 @@ class CombatMovementTests { } } + @Test + fun meleeIntentShouldNotMoveAttackerAlreadyOnAttackTile() { + TestUtils.getMockPlayer("combat_already_adjacent_attacker").use { player -> + val origin = arenaOrigin() + val staleStep = origin.transform(1, 0, 0) + place(player, origin) + configureMelee(player) + disableRun(player) + + val npc = NPC.create(100, origin.transform(0, 1, 0)) + npc.init() + try { + configureMelee(npc) + + player.attack(npc) + player.playerFlags.setUpdateSceneGraph(false) + queueWalk(player, staleStep) + CombatMovementIntents.clear() + CombatMovementIntents.request(player, npc) + CombatMovementIntents.resolve() + player.walkingQueue.update() + + assertEquals( + origin, + player.location, + "A stale melee movement intent must not make an already-adjacent attacker sidestep." + ) + assertTrue(meleeReach(player, npc)) + assertTrue(player.properties.combatPulse.isAttacking) + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun mutualMeleeMovementShouldNotOverrideQueuedStepThatKeepsAttackRange() { + TestUtils.getMockPlayer("combat_synced_step_attacker").use { player -> + val origin = arenaOrigin() + val playerStep = origin.transform(1, 0, 0) + val npcStep = origin.transform(1, 1, 0) + place(player, origin) + configureMelee(player) + disableRun(player) + + val npc = NPC.create(100, origin.transform(0, 1, 0)) + npc.init() + try { + configureMelee(npc) + + player.attack(npc) + npc.attack(player) + player.playerFlags.setUpdateSceneGraph(false) + queueWalk(player, playerStep) + queueWalk(npc, npcStep) + + assertFalse( + CombatMovementIntents.shouldMaintainMeleePressure(player, npc), + "The player's existing step should already keep melee range." + ) + assertFalse( + CombatMovementIntents.shouldMaintainMeleePressure(npc, player), + "The NPC's existing step should already keep melee range." + ) + + CombatMovementIntents.clear() + CombatMovementIntents.requestActiveMeleePressure() + CombatMovementIntents.resolve() + npc.walkingQueue.update() + player.walkingQueue.update() + + assertEquals(playerStep, player.location) + assertEquals(npcStep, npc.location) + assertTrue(meleeReach(player, npc)) + assertTrue(meleeReach(npc, player)) + } finally { + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun overlappingMeleePlayerShouldStepToOpenAttackTileInsteadOfStopping() { + TestUtils.getMockPlayer("combat_overlap_player_escape").use { player -> + val origin = arenaOrigin() + val blockedTiles = listOf( + origin.transform(0, 1, 0), + origin.transform(1, 0, 0) + ) + place(player, origin) + configureMelee(player) + disableRun(player) + + val npc = NPC.create(100, origin) + npc.init() + try { + configureMelee(npc) + blockMovementTiles(blockedTiles) + + TestUtils.advanceTicks(1, true) + CombatMovementIntents.clear() + player.playerFlags.lastSceneGraph = origin + player.playerFlags.setUpdateSceneGraph(false) + player.attack(npc) + TestUtils.advanceTicks(1, false) + + assertTrue( + player.location == origin.transform(-1, 0, 0) || player.location == origin.transform(0, -1, 0), + "An overlapped melee player should step to the open west/south side instead of stopping. " + + "player=${player.location}, npc=${npc.location}" + ) + assertTrue(CombatReach.canMelee(player, npc, CombatReach.meleeDistance(player))) + assertTrue(player.properties.combatPulse.isAttacking) + assertFalse(receivedMessage(player, "I can't reach that!")) + } finally { + unblockMovementTiles(blockedTiles) + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun overlappingMeleeNpcShouldStepToOpenAttackTileInsteadOfStalling() { + TestUtils.getMockPlayer("combat_overlap_npc_target").use { player -> + val origin = arenaOrigin() + val blockedTiles = listOf( + origin.transform(0, 1, 0), + origin.transform(1, 0, 0) + ) + place(player, origin) + configureMelee(player) + disableRun(player) + + val npc = NPC.create(100, origin) + npc.init() + try { + configureMelee(npc) + blockMovementTiles(blockedTiles) + + npc.attack(player) + TestUtils.advanceTicks(1, false) + + assertTrue( + npc.location == origin.transform(-1, 0, 0) || npc.location == origin.transform(0, -1, 0), + "An overlapped dumb melee NPC should try the open west/south side, not only north/east. " + + "npc=${npc.location}, player=${player.location}" + ) + assertTrue(CombatReach.canMelee(npc, player, CombatReach.meleeDistance(npc))) + assertTrue(npc.properties.combatPulse.isAttacking) + } finally { + unblockMovementTiles(blockedTiles) + npc.clear() + CombatMovementIntents.clear() + } + } + } + + @Test + fun mutualOverlappedMeleeCombatShouldOnlyMoveOneActorIntoAttackRange() { + TestUtils.getMockPlayer("combat_overlap_mutual_player").use { player -> + val origin = arenaOrigin() + val blockedTiles = listOf( + origin.transform(0, 1, 0), + origin.transform(1, 0, 0) + ) + place(player, origin) + configureMelee(player) + disableRun(player) + + val npc = NPC.create(100, origin) + npc.init() + try { + configureMelee(npc) + blockMovementTiles(blockedTiles) + + TestUtils.advanceTicks(1, true) + CombatMovementIntents.clear() + player.playerFlags.lastSceneGraph = origin + player.playerFlags.setUpdateSceneGraph(false) + player.attack(npc) + npc.attack(player) + TestUtils.advanceTicks(1, false) + + assertTrue(player.location != origin || npc.location != origin) + assertTrue( + player.location == origin || npc.location == origin, + "When both overlapped actors are attacking, one side stepping out is enough. " + + "player=${player.location}, npc=${npc.location}" + ) + assertTrue(CombatReach.canMelee(player, npc, CombatReach.meleeDistance(player))) + assertTrue(CombatReach.canMelee(npc, player, CombatReach.meleeDistance(npc))) + assertFalse(receivedMessage(player, "I can't reach that!")) + } finally { + unblockMovementTiles(blockedTiles) + npc.clear() + CombatMovementIntents.clear() + } + } + } + @Test fun playerShouldChaseMovingMeleeNpcWithoutGenericInteractionMovementPulse() { TestUtils.getMockPlayer("combat_npc_chaser").use { player -> @@ -863,6 +1067,11 @@ class CombatMovementTests { entity.walkingQueue.addPath(destination.x, destination.y) } + private fun queueWalk(entity: Entity, destination: Location) { + entity.walkingQueue.reset(false) + entity.walkingQueue.addPath(destination.x, destination.y) + } + private fun queueRunPath(entity: Entity, path: List) { entity.walkingQueue.reset(true) for (location in path) { @@ -951,7 +1160,7 @@ class CombatMovementTests { } private fun meleeReach(attacker: Entity, victim: Entity): Boolean { - return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 1.0 + return CombatReach.canMelee(attacker, victim, CombatReach.meleeDistance(attacker)) } private fun magicReach(attacker: Entity, victim: Entity): Boolean {