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 {