Added combat movement intent resolution

Replaced CombatPulse's private MovementPulse pathing with an engine-side CombatMovementIntents queue resolved before normal walking queues. Exposed candidate attack tiles from CombatMovementPlanner so intents can select and reserve deterministic melee approach paths.

Enabled the first pending combat movement scenario using an open wilderness fixture, added intent resolver coverage, and updated the combat movement rewrite living document.
This commit is contained in:
dam 2026-04-29 17:00:00 +03:00
parent 71e8812124
commit eed4fd4888
No known key found for this signature in database
GPG key ID: 4AF4E722399663FB
6 changed files with 187 additions and 21 deletions

View file

@ -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<Entity, Intent>()
@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<Intent> { it.attacker.index }
.thenBy { it.target.index }
)
intents.clear()
val reservedTiles = LinkedHashSet<Location>()
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<Location>) {
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<Location> {
val tiles = ArrayList<Location>(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
}
}
}

View file

@ -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<Location> {
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<Location> { it.getDistance(attacker.location) }
.thenBy { it.x }
.thenBy { it.y }

View file

@ -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
}
}
}
}

View file

@ -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()

View file

@ -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
}

View file

@ -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())
}
}
}
}