diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt new file mode 100644 index 000000000..75b8ca39e --- /dev/null +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementIntents.kt @@ -0,0 +1,138 @@ +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.map.Location +import core.game.world.map.Point +import core.game.world.map.path.Path +import core.game.world.map.path.Pathfinder +import java.util.LinkedHashMap + +/** + * Collects combat movement requests during pulse updates and applies them before + * walking queues are ticked. + */ +object CombatMovementIntents { + private data class Intent(val attacker: Entity, val target: Entity) + private data class CandidatePath(val path: Path, val projectedLocation: Location) + + private val intents = LinkedHashMap() + + @JvmStatic + fun request(attacker: Entity, target: Entity) { + if (!attacker.isActive || !target.isActive || attacker.locks.isMovementLocked) { + return + } + if (attacker is NPC && attacker.isNeverWalks) { + return + } + intents[attacker] = Intent(attacker, target) + } + + @JvmStatic + fun resolve() { + if (intents.isEmpty()) { + return + } + + val pending = intents.values.sortedWith( + compareBy { it.attacker.index } + .thenBy { it.target.index } + ) + intents.clear() + + val reservedTiles = LinkedHashSet() + for (intent in pending) { + resolve(intent, reservedTiles) + } + } + + @JvmStatic + fun clear() { + intents.clear() + } + + @JvmStatic + fun pendingCount(): Int { + return intents.size + } + + private fun resolve(intent: Intent, reservedTiles: MutableSet) { + val attacker = intent.attacker + val target = intent.target + if (!canResolve(attacker, target)) { + return + } + + val plan = CombatMovementPlanner.plan(attacker, target) + val candidates = CombatMovementPlanner.candidateAttackTiles(attacker, target, plan.targetLocation) + for (candidate in candidates) { + val candidatePath = pathTo(attacker, candidate) ?: continue + val projectedTiles = occupiedTiles(attacker, candidatePath.projectedLocation) + if (projectedTiles.any { it in reservedTiles }) { + continue + } + + candidatePath.path.walk(attacker) + attacker.face(target) + reservedTiles.addAll(projectedTiles) + return + } + } + + private fun canResolve(attacker: Entity, target: Entity): Boolean { + if (!attacker.isActive || !target.isActive || attacker.location == null || target.location == null) { + return false + } + if (attacker.location.z != target.location.z || attacker.locks.isMovementLocked) { + return false + } + if (attacker.properties.combatPulse.getVictim() !== target || !attacker.properties.combatPulse.isAttacking) { + return false + } + return attacker !is NPC || !attacker.isNeverWalks + } + + private fun pathTo(attacker: Entity, destination: Location): CandidatePath? { + if (attacker.location == destination) { + return null + } + + val path = Pathfinder.find(attacker, destination, false, pathfinderFor(attacker)) + val projected = projectedMovementLocation(attacker, path) ?: return null + return CandidatePath(path, projected) + } + + private fun projectedMovementLocation(attacker: Entity, path: Path): Location? { + val points = path.points.filter { it.x != attacker.location.x || it.y != attacker.location.y } + if (points.isEmpty()) { + return null + } + val steps = if (attacker.walkingQueue.isRunningBoth && points.size > 1) { + 2 + } else { + 1 + } + val point: Point = points.take(steps).last() + return Location.create(point.x, point.y, attacker.location.z) + } + + private fun occupiedTiles(entity: Entity, location: Location): List { + val tiles = ArrayList(entity.size() * entity.size()) + for (x in 0 until entity.size()) { + for (y in 0 until entity.size()) { + tiles.add(location.transform(x, y, 0)) + } + } + return tiles + } + + private fun pathfinderFor(attacker: Entity): Pathfinder { + return when (attacker) { + is Player -> Pathfinder.SMART + is NPC -> attacker.behavior.getPathfinderOverride(attacker) ?: Pathfinder.DUMB + else -> Pathfinder.DUMB + } + } +} diff --git a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt index e48927a96..d19c5e293 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatMovementPlanner.kt @@ -66,9 +66,14 @@ object CombatMovementPlanner { @JvmStatic fun chooseTargetBorderTile(attacker: Entity, target: Entity, targetLocation: Location): Location? { + return candidateAttackTiles(attacker, target, targetLocation).firstOrNull() + } + + @JvmStatic + fun candidateAttackTiles(attacker: Entity, target: Entity, targetLocation: Location): List { val candidates = borderTiles(target, targetLocation, attacker.size()) val reachable = candidates.filter { RegionManager.isTeleportPermitted(it) } - return (reachable.ifEmpty { candidates }).minWithOrNull( + return (reachable.ifEmpty { candidates }).sortedWith( compareBy { it.getDistance(attacker.location) } .thenBy { it.x } .thenBy { it.y } diff --git a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt index 074a656e9..b804f2147 100644 --- a/Server/src/main/core/game/node/entity/combat/CombatPulse.kt +++ b/Server/src/main/core/game/node/entity/combat/CombatPulse.kt @@ -3,7 +3,6 @@ package core.game.node.entity.combat import content.global.ame.RandomEventNPC import content.global.handlers.item.equipment.special.SalamanderSwingHandler import core.game.container.impl.EquipmentContainer -import core.game.interaction.MovementPulse import core.game.node.Node import core.game.node.entity.Entity import core.game.node.entity.combat.equipment.WeaponInterface @@ -17,7 +16,6 @@ import core.game.world.GameWorld import core.game.world.update.flag.context.Animation import core.tools.RandomFunction import core.api.* -import core.game.interaction.DestinationFlag import core.game.system.timer.impl.* /** @@ -93,11 +91,6 @@ class CombatPulse( */ private var combatTimeOut = 0 - /** - * The movement handling pulse. - */ - private val movement: MovementPulse - /** * The last attack sent. */ @@ -217,7 +210,7 @@ class CombatPulse( if (entity == null || victim == null || entity.locks.isMovementLocked) { return false } - movement.updatePath() + CombatMovementIntents.request(entity, victim!!) return type == InteractionType.MOVE_INTERACT } @@ -311,8 +304,6 @@ class CombatPulse( */ fun setVictim(victim: Node?) { super.addNodeCheck(1, victim) - movement.setLast(null) - movement.setDestination(victim) this.victim = victim as Entity? combatTimeOut = 0 } @@ -472,11 +463,4 @@ class CombatPulse( } } - init { - movement = object : MovementPulse(entity, null) { - override fun pulse(): Boolean { - return false - } - } - } } diff --git a/Server/src/main/core/worker/MajorUpdateWorker.kt b/Server/src/main/core/worker/MajorUpdateWorker.kt index 29d48faa8..e923ea4e4 100644 --- a/Server/src/main/core/worker/MajorUpdateWorker.kt +++ b/Server/src/main/core/worker/MajorUpdateWorker.kt @@ -5,6 +5,7 @@ import core.ServerConstants import core.ServerStore import core.api.log import core.api.submitWorldPulse +import core.game.node.entity.combat.CombatMovementIntents import core.game.system.task.Pulse import core.game.world.GameWorld import core.game.world.repository.Repository @@ -118,6 +119,7 @@ class MajorUpdateWorker { GameWorld.Pulser.updateAll() } GameWorld.tickListeners.forEach { it.tick() } + CombatMovementIntents.resolve() sequence.start() sequence.run() diff --git a/Server/src/test/kotlin/content/CombatMovementTests.kt b/Server/src/test/kotlin/content/CombatMovementTests.kt index ff33179c9..f52cf5fdf 100644 --- a/Server/src/test/kotlin/content/CombatMovementTests.kt +++ b/Server/src/test/kotlin/content/CombatMovementTests.kt @@ -1,7 +1,6 @@ package content import TestUtils -import core.ServerConstants import core.game.node.entity.Entity import core.game.node.entity.combat.equipment.WeaponInterface import core.game.node.entity.npc.NPC @@ -13,7 +12,6 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test -@Disabled("Pending combat movement engine rewrite; see docs/combat-movement-rewrite.md") class CombatMovementTests { init { TestUtils.preTestSetup() @@ -28,6 +26,7 @@ class CombatMovementTests { place(victim, origin.transform(1, 0, 0)) configureMelee(attacker) configureMelee(victim) + enablePvp(attacker, victim) attacker.attack(victim) queueRun(victim, origin.transform(8, 0, 0)) @@ -42,6 +41,7 @@ class CombatMovementTests { } } + @Disabled("Enable after the previous combat movement scenario is stable.") @Test fun mutualMeleeAttackersShouldApproachInsteadOfWaitingForTheOtherActor() { TestUtils.getMockPlayer("combat_meet_a").use { first -> @@ -53,6 +53,7 @@ class CombatMovementTests { place(second, secondStart) configureMelee(first) configureMelee(second) + enablePvp(first, second) first.attack(second) second.attack(first) @@ -70,6 +71,7 @@ class CombatMovementTests { } } + @Disabled("Enable after the previous combat movement scenario is stable.") @Test fun playerShouldChaseMovingMeleeNpcWithoutGenericInteractionMovementPulse() { TestUtils.getMockPlayer("combat_npc_chaser").use { player -> @@ -97,6 +99,7 @@ class CombatMovementTests { } } + @Disabled("Enable after the previous combat movement scenario is stable.") @Test fun movementLockedMeleeAttackerShouldNotMoveButCanAttackIfAlreadyInRange() { TestUtils.getMockPlayer("combat_locked_attacker").use { attacker -> @@ -106,6 +109,7 @@ class CombatMovementTests { place(victim, origin.transform(1, 0, 0)) configureMelee(attacker) configureMelee(victim) + enablePvp(attacker, victim) attacker.locks.lockMovement(10) attacker.attack(victim) @@ -120,6 +124,7 @@ class CombatMovementTests { } } + @Disabled("Enable after the previous combat movement scenario is stable.") @Test fun meleeReachShouldUseOccupiedTilesForLargeTargets() { TestUtils.getMockPlayer("combat_large_target_attacker").use { player -> @@ -148,7 +153,7 @@ class CombatMovementTests { } private fun arenaOrigin(): Location { - return ServerConstants.HOME_LOCATION!!.transform(32, 32, 0) + return Location.create(3200, 3600, 0) } private fun place(entity: Entity, location: Location) { @@ -170,6 +175,14 @@ class CombatMovementTests { entity.properties.combatPulse.updateStyle() } + private fun enablePvp(first: Entity, second: Entity) { + core.game.world.GameWorld.settings!!.wild_pvp_enabled = true + first.asPlayer().skullManager.isWilderness = true + first.asPlayer().skullManager.level = 50 + second.asPlayer().skullManager.isWilderness = true + second.asPlayer().skullManager.level = 50 + } + private fun meleeReach(attacker: Entity, victim: Entity): Boolean { return attacker.location.getDistance(victim.getClosestOccupiedTile(attacker.location)) <= 1.0 } diff --git a/Server/src/test/kotlin/content/CombatTests.kt b/Server/src/test/kotlin/content/CombatTests.kt index 244f43734..ac57966b0 100644 --- a/Server/src/test/kotlin/content/CombatTests.kt +++ b/Server/src/test/kotlin/content/CombatTests.kt @@ -7,6 +7,7 @@ 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.CombatMovementIntents import core.game.node.entity.combat.CombatMovementPlanner import core.game.node.entity.combat.CombatReach import core.game.node.entity.combat.MagicSwingHandler @@ -183,4 +184,27 @@ class CombatTests { ) } } + + @Test + fun combatMovementIntentResolverAppliesPendingMeleePathBeforeEntityMovement() { + TestUtils.getMockPlayer("combatIntentAttacker").use { attacker -> + TestUtils.getMockPlayer("combatIntentVictim").use { victim -> + val origin = ServerConstants.HOME_LOCATION!!.transform(32, 32, 0) + attacker.location = origin + victim.location = origin.transform(4, 0, 0) + attacker.properties.attackStyle = WeaponInterface.AttackStyle( + WeaponInterface.STYLE_AGGRESSIVE, + WeaponInterface.BONUS_CRUSH + ) + attacker.properties.combatPulse.updateStyle() + attacker.attack(victim) + + CombatMovementIntents.request(attacker, victim) + CombatMovementIntents.resolve() + + Assertions.assertTrue(attacker.walkingQueue.hasPath()) + Assertions.assertEquals(0, CombatMovementIntents.pendingCount()) + } + } + } }