From 5b76266292eb74c8ac373e4bdc60e4bdf361c7ad Mon Sep 17 00:00:00 2001 From: Mads Boddum Date: Sun, 6 Sep 2026 19:46:18 +0200 Subject: [PATCH 1/6] Auto attack support The client sends the defaultAction command when a toolbar slot is chosen as the default attack, carrying the ability name, or nothing at all when the choice is cleared. The choice is kept on the CreatureObject, because the client only sends it when a toolbar slot is ctrl-clicked or when it starts up, and so has to survive logging out. AutoAttackService queues that command at the player's look-at target while they are in combat, paced by the weapon's modified attack speed. Clearing the choice stops the attacks, as does logging out or running out of action or mind, for which CommandQueueService now reports a failed combat command. Attacking again resumes them. The client discards a command timer whose sequence id is zero, so a command the server started on its own continues the client's own sequence, otherwise no cooldown is drawn on the toolbar. --- .../intents/gameplay/combat/CombatIntents.kt | 8 + .../callbacks/combat/CmdDefaultAction.kt | 53 ++++++ .../objects/swg/creature/CreatureObject.java | 15 ++ .../gameplay/combat/AutoAttackService.kt | 165 ++++++++++++++++++ .../services/gameplay/combat/CombatManager.kt | 2 +- .../commands/CommandExecutionService.java | 2 + .../global/commands/CommandQueueService.kt | 23 ++- 7 files changed, 265 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/combat/CmdDefaultAction.kt create mode 100644 src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt diff --git a/src/main/java/com/projectswg/holocore/intents/gameplay/combat/CombatIntents.kt b/src/main/java/com/projectswg/holocore/intents/gameplay/combat/CombatIntents.kt index 842e843ae..1ded7284a 100644 --- a/src/main/java/com/projectswg/holocore/intents/gameplay/combat/CombatIntents.kt +++ b/src/main/java/com/projectswg/holocore/intents/gameplay/combat/CombatIntents.kt @@ -26,6 +26,8 @@ package com.projectswg.holocore.intents.gameplay.combat import com.projectswg.common.data.location.Terrain +import com.projectswg.holocore.resources.gameplay.combat.CombatStatus +import com.projectswg.holocore.resources.support.global.commands.Command import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject import com.projectswg.holocore.resources.support.objects.swg.tangible.TangibleObject import com.projectswg.holocore.services.gameplay.combat.states.CombatState @@ -40,6 +42,12 @@ data class CreatureRevivedIntent(val creature: CreatureObject) : Intent() data class EnterCombatIntent(val source: TangibleObject, val target: TangibleObject) : Intent() data class ExitCombatIntent(val source: TangibleObject) : Intent() data class CloneActivatedIntent(val creature: CreatureObject, val diedOnTerrain: Terrain) : Intent() +data class CombatCommandFailedIntent(val source: CreatureObject, val status: CombatStatus) : Intent() + +/* + * The command the client picked as its default attack, or null when it cleared the choice + */ +data class DefaultActionIntent(val creature: CreatureObject, val command: Command?) : Intent() /* * Combat event requests diff --git a/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/combat/CmdDefaultAction.kt b/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/combat/CmdDefaultAction.kt new file mode 100644 index 000000000..85a618bed --- /dev/null +++ b/src/main/java/com/projectswg/holocore/resources/support/global/commands/callbacks/combat/CmdDefaultAction.kt @@ -0,0 +1,53 @@ +/*********************************************************************************** + * Copyright (c) 2026 /// Project SWG /// www.projectswg.com * + * * + * ProjectSWG is an emulation project for Star Wars Galaxies founded on * + * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. * + * Our goal is to create one or more emulators which will provide servers for * + * players to continue playing a game similar to the one they used to play. * + * * + * This file is part of Holocore. * + * * + * --------------------------------------------------------------------------------* + * * + * Holocore is free software: you can redistribute it and/or modify * + * it under the terms of the GNU Affero General Public License as * + * published by the Free Software Foundation, either version 3 of the * + * License, or (at your option) any later version. * + * * + * Holocore is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Affero General Public License for more details. * + * * + * You should have received a copy of the GNU Affero General Public License * + * along with Holocore. If not, see . * + ***********************************************************************************/ +package com.projectswg.holocore.resources.support.global.commands.callbacks.combat + +import com.projectswg.holocore.intents.gameplay.combat.DefaultActionIntent +import com.projectswg.holocore.resources.support.data.server_info.loader.ServerData +import com.projectswg.holocore.resources.support.global.commands.ICmdCallback +import com.projectswg.holocore.resources.support.global.player.Player +import com.projectswg.holocore.resources.support.objects.swg.SWGObject + +class CmdDefaultAction : ICmdCallback { + override fun execute(player: Player, target: SWGObject?, args: String) { + val creature = player.creatureObject + val commandName = args.trim().lowercase() + + if (commandName.isEmpty()) { + DefaultActionIntent(creature, null).broadcast() + return + } + + val command = ServerData.commands.getCommand(commandName) ?: return + val grantedCommands = creature.commands.map { it.lowercase() }.toSet() + + if (!grantedCommands.contains(commandName)) { + return + } + + DefaultActionIntent(creature, command).broadcast() + } +} diff --git a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/creature/CreatureObject.java b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/creature/CreatureObject.java index dd1b64932..77830cd0a 100644 --- a/src/main/java/com/projectswg/holocore/resources/support/objects/swg/creature/CreatureObject.java +++ b/src/main/java/com/projectswg/holocore/resources/support/objects/swg/creature/CreatureObject.java @@ -73,6 +73,7 @@ public class CreatureObject extends TangibleObject { private Race race = Race.HUMAN_MALE; private long lastIncapTime = 0; private TradeSession tradeSession = null; + private String defaultAttack = null; private SWGSet skills = new SWGSet<>(1, 3, StringType.ASCII); private final AttributesMutable baseAttributes; @@ -396,6 +397,18 @@ public void setTradeSession(TradeSession tradeSession) { this.tradeSession = tradeSession; } + /** + * @return name of the command to repeat while in combat, or {@code null} if no default attack is chosen + */ + @Nullable + public String getDefaultAttack() { + return defaultAttack; + } + + public void setDefaultAttack(@Nullable String defaultAttack) { + this.defaultAttack = defaultAttack; + } + public void setPosture(Posture posture) { creo3.setPosture(posture); } @@ -1069,6 +1082,7 @@ public void saveMongo(MongoData data) { data.putString("race", race.name()); data.putArray("skills", skills); data.putDocument("baseAttributes", baseAttributes); + data.putString("defaultAttack", defaultAttack); } @Override @@ -1082,6 +1096,7 @@ public void readMongo(MongoData data) { race = Race.valueOf(data.getString("race", race.name())); skills.addAll(data.getArray("skills", String.class)); data.getDocument("baseAttributes", baseAttributes); + defaultAttack = data.getString("defaultAttack"); } private static class Container { diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt new file mode 100644 index 000000000..46349e455 --- /dev/null +++ b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt @@ -0,0 +1,165 @@ +/*********************************************************************************** + * Copyright (c) 2026 /// Project SWG /// www.projectswg.com * + * * + * ProjectSWG is an emulation project for Star Wars Galaxies founded on * + * July 7th, 2011 after SOE announced the official shutdown of Star Wars Galaxies. * + * Our goal is to create one or more emulators which will provide servers for * + * players to continue playing a game similar to the one they used to play. * + * * + * This file is part of Holocore. * + * * + * --------------------------------------------------------------------------------* + * * + * Holocore is free software: you can redistribute it and/or modify * + * it under the terms of the GNU Affero General Public License as * + * published by the Free Software Foundation, either version 3 of the * + * License, or (at your option) any later version. * + * * + * Holocore is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU Affero General Public License for more details. * + * * + * You should have received a copy of the GNU Affero General Public License * + * along with Holocore. If not, see . * + ***********************************************************************************/ +package com.projectswg.holocore.services.gameplay.combat + +import com.projectswg.common.data.encodables.tangible.Posture +import com.projectswg.holocore.intents.gameplay.combat.CombatCommandFailedIntent +import com.projectswg.holocore.intents.gameplay.combat.DefaultActionIntent +import com.projectswg.holocore.intents.gameplay.combat.ExitCombatIntent +import com.projectswg.holocore.intents.support.global.command.QueueCommandIntent +import com.projectswg.holocore.intents.support.global.zone.PlayerEventIntent +import com.projectswg.holocore.resources.gameplay.combat.CombatStatus +import com.projectswg.holocore.resources.support.data.server_info.StandardLog +import com.projectswg.holocore.resources.support.data.server_info.loader.ServerData +import com.projectswg.holocore.resources.support.global.player.PlayerEvent +import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject +import com.projectswg.holocore.resources.support.objects.swg.weapon.WeaponObject +import com.projectswg.holocore.services.support.objects.ObjectStorageService.ObjectLookup +import com.projectswg.holocore.utilities.HolocoreCoroutine +import com.projectswg.holocore.utilities.cancelAndWait +import com.projectswg.holocore.utilities.launchWithFixedRate +import me.joshlarson.jlcommon.control.IntentHandler +import me.joshlarson.jlcommon.control.Service +import java.util.concurrent.ConcurrentHashMap + +/** + * Repeats the default attack a player chose on their toolbar for as long as they are in combat. + * Clearing the choice stops the attacks, as does logging out or running out of action or mind. + * + * The choice itself lives on the [CreatureObject], because the client only sends it when a toolbar + * slot is ctrl-clicked or when it starts up. + */ +class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Service() { + + /** Creatures currently auto-attacking, mapped to the time their next attack is due. */ + private val nextAttackTimes: MutableMap = ConcurrentHashMap() + private val coroutineScope = HolocoreCoroutine.childScope() + + override fun initialize(): Boolean { + coroutineScope.launchWithFixedRate(delayBetweenAttackChecks) { attemptAttacks() } + return true + } + + override fun terminate(): Boolean { + coroutineScope.cancelAndWait() + return super.terminate() + } + + @IntentHandler + private fun handleDefaultActionIntent(dai: DefaultActionIntent) { + val creature = dai.creature + val command = dai.command + + if (command == null) { + StandardLog.onPlayerTrace(this, creature, "cleared their default action") + creature.defaultAttack = null + stopAttacking(creature) + } else { + StandardLog.onPlayerTrace(this, creature, "set their default action to %s", command.name) + creature.defaultAttack = command.name + startAttacking(creature) + } + } + + @IntentHandler + private fun handleCombatCommandFailedIntent(ccfi: CombatCommandFailedIntent) { + if (ccfi.status == CombatStatus.TOO_TIRED) { + StandardLog.onPlayerTrace(this, ccfi.source, "paused their default action") + stopAttacking(ccfi.source) + } + } + + @IntentHandler + private fun handleQueueCommandIntent(qci: QueueCommandIntent) { + if (qci.counter != 0) { + startAttacking(qci.source) // a command the client sent itself means the player is attacking again + } + } + + @IntentHandler + private fun handleExitCombatIntent(eci: ExitCombatIntent) { + stopAttacking(eci.source as? CreatureObject) + } + + @IntentHandler + private fun handlePlayerEventIntent(pei: PlayerEventIntent) { + when (pei.event) { + PlayerEvent.PE_LOGGED_OUT, PlayerEvent.PE_DESTROYED -> stopAttacking(pei.player.creatureObject) + else -> {} + } + } + + private fun startAttacking(creature: CreatureObject?) { + if (creature?.defaultAttack == null) { + return + } + + nextAttackTimes.putIfAbsent(creature, 0) + } + + private fun stopAttacking(creature: CreatureObject?) { + if (creature == null) { + return + } + + nextAttackTimes.remove(creature) + } + + private fun attemptAttacks() { + val now = System.currentTimeMillis() + + for ((creature, nextAttackTime) in nextAttackTimes) { + if (!creature.isInCombat || now < nextAttackTime) { + continue + } + + val command = ServerData.commands.getCommand(creature.defaultAttack ?: continue) ?: continue + val weapon = creature.equippedWeapon ?: continue + val target = findTarget(creature) ?: continue + + nextAttackTimes[creature] = now + attackDelay(creature, weapon) + QueueCommandIntent(creature, target, "", command, 0).broadcast() + } + } + + private fun attackDelay(creature: CreatureObject, weapon: WeaponObject): Long { + return (weapon.getModdedWeaponAttackSpeedWithCap(creature) * 1000).toLong() + } + + private fun findTarget(creature: CreatureObject): CreatureObject? { + val lookAtTarget = ObjectLookup.getObjectById(creature.lookAtTargetId) as? CreatureObject ?: return null + + return if (isValidTarget(creature, lookAtTarget)) lookAtTarget else null + } + + private fun isValidTarget(creature: CreatureObject, target: CreatureObject): Boolean { + if (target.posture == Posture.INCAPACITATED || target.posture == Posture.DEAD) { + return false + } + + return target.isAttackable(creature) + } +} diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/combat/CombatManager.kt b/src/main/java/com/projectswg/holocore/services/gameplay/combat/CombatManager.kt index 86a1b1303..fa5e71e5f 100644 --- a/src/main/java/com/projectswg/holocore/services/gameplay/combat/CombatManager.kt +++ b/src/main/java/com/projectswg/holocore/services/gameplay/combat/CombatManager.kt @@ -33,5 +33,5 @@ import com.projectswg.holocore.services.gameplay.combat.loot.LootManager import me.joshlarson.jlcommon.control.Manager import me.joshlarson.jlcommon.control.ManagerStructure -@ManagerStructure(children = [BuffService::class, CloningService::class, DuelService::class, LootManager::class, CombatDeathblowService::class, CombatExperienceService::class, CombatNpcService::class, CombatRegenerationService::class, CombatStatusService::class, CombatKnockdownService::class, CombatStateService::class]) +@ManagerStructure(children = [BuffService::class, CloningService::class, DuelService::class, LootManager::class, CombatDeathblowService::class, CombatExperienceService::class, CombatNpcService::class, CombatRegenerationService::class, CombatStatusService::class, CombatKnockdownService::class, CombatStateService::class, AutoAttackService::class]) class CombatManager : Manager() diff --git a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java index d52d7cc36..3dc830cd2 100644 --- a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java +++ b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandExecutionService.java @@ -36,6 +36,7 @@ import com.projectswg.holocore.resources.support.global.commands.callbacks.chat.friend.*; import com.projectswg.holocore.resources.support.global.commands.callbacks.combat.CmdAttack; import com.projectswg.holocore.resources.support.global.commands.callbacks.combat.CmdCoupDeGrace; +import com.projectswg.holocore.resources.support.global.commands.callbacks.combat.CmdDefaultAction; import com.projectswg.holocore.resources.support.global.commands.callbacks.combat.CmdDuel; import com.projectswg.holocore.resources.support.global.commands.callbacks.combat.CmdEndDuel; import com.projectswg.holocore.resources.support.global.commands.callbacks.conversation.*; @@ -182,6 +183,7 @@ private void addCombatScripts() { registerCppCallback("duel", CmdDuel::new); registerCppCallback("endDuel", CmdEndDuel::new); registerScriptCallback("attack", CmdAttack::new); + registerCppCallback("defaultAction", CmdDefaultAction::new); } private void addLootScripts() { diff --git a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt index 32ba266a0..995dfea8b 100644 --- a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt +++ b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt @@ -31,6 +31,7 @@ import com.projectswg.common.data.combat.HitType import com.projectswg.common.data.combat.TargetType import com.projectswg.common.data.encodables.oob.StringId import com.projectswg.common.network.packets.swg.zone.object_controller.* +import com.projectswg.holocore.intents.gameplay.combat.CombatCommandFailedIntent import com.projectswg.holocore.intents.gameplay.combat.ExitCombatIntent import com.projectswg.holocore.intents.support.global.command.ExecuteCommandIntent import com.projectswg.holocore.intents.support.global.command.QueueCommandIntent @@ -68,6 +69,7 @@ import java.util.stream.Collectors class CommandQueueService @JvmOverloads constructor(private val delayBetweenCheckingCommandQueue: Long = 100, toHitDie: Die = RandomDie(), knockdownDie: Die = RandomDie(), woundDie: Die = RandomDie(), private val skipWarmup: Boolean = false) : Service() { private val combatQueueMap: MutableMap = ConcurrentHashMap() + private val lastClientCounters: MutableMap = ConcurrentHashMap() private val combatCommandHandler: CombatCommandHandler = CombatCommandHandler(toHitDie, knockdownDie, woundDie) private val coroutineScope = HolocoreCoroutine.childScope() @@ -100,6 +102,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec } val targetId: Long = p.targetId val target = if (targetId != 0L) ObjectLookup.getObjectById(targetId) else null + lastClientCounters[gpi.player.creatureObject] = p.counter QueueCommandIntent(gpi.player.creatureObject, target, p.arguments, command, p.counter).broadcast() } else if (p is IntendedTarget) { if (p.targetId == 0L) combatQueueMap.remove(gpi.player.creatureObject) @@ -112,13 +115,28 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec if (pei.event == PlayerEvent.PE_LOGGED_OUT) { // No reason to keep their combat queue in the map if they log out // This also prevents queued commands from executing after the player logs out - if (creature != null) combatQueueMap.remove(creature) + if (creature != null) { + combatQueueMap.remove(creature) + lastClientCounters.remove(creature) + } } } @IntentHandler private fun handleQueueCommandIntent(qci: QueueCommandIntent) { - getQueue(qci.source).queueCommand(EnqueuedCommand(qci.source, qci.command, qci.target, qci.arguments, qci.counter)) + getQueue(qci.source).queueCommand(EnqueuedCommand(qci.source, qci.command, qci.target, qci.arguments, counterFor(qci))) + } + + /** + * The client discards a command timer whose sequence id is zero, so a command the server + * started on its own continues the client's own sequence instead. + */ + private fun counterFor(qci: QueueCommandIntent): Int { + if (qci.counter != 0 || qci.source.owner == null) { + return qci.counter + } + + return lastClientCounters.merge(qci.source, 1) { previous, _ -> previous + 1 } ?: 1 } @IntentHandler @@ -225,6 +243,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec handleStatus(source, combatCommand, combatStatus) if (combatStatus != CombatStatus.SUCCESS) { + CombatCommandFailedIntent(source, combatStatus).broadcast() sendCommandFailed(command) return } From e198d8a3689056893e0eccf71edd4cce5546edda Mon Sep 17 00:00:00 2001 From: Mads Boddum Date: Tue, 8 Sep 2026 00:06:37 +0200 Subject: [PATCH 2/6] Pace auto attacks by nanoTime currentTimeMillis follows the system clock and jumps around with NTP, so an adjustment could delay or skip an attack. nanoTime is monotonic. --- .../services/gameplay/combat/AutoAttackService.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt index 46349e455..f9607d9d6 100644 --- a/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt +++ b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt @@ -54,7 +54,7 @@ import java.util.concurrent.ConcurrentHashMap */ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Service() { - /** Creatures currently auto-attacking, mapped to the time their next attack is due. */ + /** Creatures currently auto-attacking, mapped to the [System.nanoTime] their next attack is due. */ private val nextAttackTimes: MutableMap = ConcurrentHashMap() private val coroutineScope = HolocoreCoroutine.childScope() @@ -117,7 +117,7 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv return } - nextAttackTimes.putIfAbsent(creature, 0) + nextAttackTimes.putIfAbsent(creature, System.nanoTime()) } private fun stopAttacking(creature: CreatureObject?) { @@ -129,7 +129,7 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv } private fun attemptAttacks() { - val now = System.currentTimeMillis() + val now = System.nanoTime() for ((creature, nextAttackTime) in nextAttackTimes) { if (!creature.isInCombat || now < nextAttackTime) { @@ -146,7 +146,7 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv } private fun attackDelay(creature: CreatureObject, weapon: WeaponObject): Long { - return (weapon.getModdedWeaponAttackSpeedWithCap(creature) * 1000).toLong() + return (weapon.getModdedWeaponAttackSpeedWithCap(creature) * 1E9).toLong() } private fun findTarget(creature: CreatureObject): CreatureObject? { From 2e654c3bce491ba0d2798d084086b5c66cdf87d5 Mon Sep 17 00:00:00 2001 From: Mads Boddum Date: Tue, 8 Sep 2026 00:10:32 +0200 Subject: [PATCH 3/6] Track the command sequence id on the combat queue One map per creature instead of two. The sequence id now shares the combat queue's lifetime, so it restarts whenever the player leaves combat or deselects their target. --- .../support/global/commands/CommandQueueService.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt index 995dfea8b..76ccaae3d 100644 --- a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt +++ b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt @@ -69,7 +69,6 @@ import java.util.stream.Collectors class CommandQueueService @JvmOverloads constructor(private val delayBetweenCheckingCommandQueue: Long = 100, toHitDie: Die = RandomDie(), knockdownDie: Die = RandomDie(), woundDie: Die = RandomDie(), private val skipWarmup: Boolean = false) : Service() { private val combatQueueMap: MutableMap = ConcurrentHashMap() - private val lastClientCounters: MutableMap = ConcurrentHashMap() private val combatCommandHandler: CombatCommandHandler = CombatCommandHandler(toHitDie, knockdownDie, woundDie) private val coroutineScope = HolocoreCoroutine.childScope() @@ -102,7 +101,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec } val targetId: Long = p.targetId val target = if (targetId != 0L) ObjectLookup.getObjectById(targetId) else null - lastClientCounters[gpi.player.creatureObject] = p.counter + getQueue(gpi.player.creatureObject).lastClientCounter = p.counter QueueCommandIntent(gpi.player.creatureObject, target, p.arguments, command, p.counter).broadcast() } else if (p is IntendedTarget) { if (p.targetId == 0L) combatQueueMap.remove(gpi.player.creatureObject) @@ -117,7 +116,6 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec // This also prevents queued commands from executing after the player logs out if (creature != null) { combatQueueMap.remove(creature) - lastClientCounters.remove(creature) } } } @@ -136,7 +134,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec return qci.counter } - return lastClientCounters.merge(qci.source, 1) { previous, _ -> previous + 1 } ?: 1 + return getQueue(qci.source).nextServerCounter() } @IntentHandler @@ -158,6 +156,14 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec private val commandQueue: Queue = PriorityQueue() private val activeCooldownGroups: MutableSet = ConcurrentHashMap.newKeySet() + /** Sequence id of the command the client sent most recently, which server-started commands continue from. */ + var lastClientCounter: Int = 0 + + @Synchronized + fun nextServerCounter(): Int { + return ++lastClientCounter + } + @Synchronized fun executeNextCommand() { val peek = commandQueue.peek() ?: return From eff9368f93dad0cd12ce35b8f341946ada994b8b Mon Sep 17 00:00:00 2001 From: Mads Boddum Date: Tue, 8 Sep 2026 01:58:59 +0200 Subject: [PATCH 4/6] Clean up command counter logic --- .../global/commands/CommandQueueService.kt | 32 ++++--------------- 1 file changed, 7 insertions(+), 25 deletions(-) diff --git a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt index 76ccaae3d..e79597dcd 100644 --- a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt +++ b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt @@ -101,7 +101,6 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec } val targetId: Long = p.targetId val target = if (targetId != 0L) ObjectLookup.getObjectById(targetId) else null - getQueue(gpi.player.creatureObject).lastClientCounter = p.counter QueueCommandIntent(gpi.player.creatureObject, target, p.arguments, command, p.counter).broadcast() } else if (p is IntendedTarget) { if (p.targetId == 0L) combatQueueMap.remove(gpi.player.creatureObject) @@ -114,27 +113,13 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec if (pei.event == PlayerEvent.PE_LOGGED_OUT) { // No reason to keep their combat queue in the map if they log out // This also prevents queued commands from executing after the player logs out - if (creature != null) { - combatQueueMap.remove(creature) - } + if (creature != null) combatQueueMap.remove(creature) } } @IntentHandler private fun handleQueueCommandIntent(qci: QueueCommandIntent) { - getQueue(qci.source).queueCommand(EnqueuedCommand(qci.source, qci.command, qci.target, qci.arguments, counterFor(qci))) - } - - /** - * The client discards a command timer whose sequence id is zero, so a command the server - * started on its own continues the client's own sequence instead. - */ - private fun counterFor(qci: QueueCommandIntent): Int { - if (qci.counter != 0 || qci.source.owner == null) { - return qci.counter - } - - return getQueue(qci.source).nextServerCounter() + getQueue(qci.source).queueCommand(qci) } @IntentHandler @@ -156,13 +141,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec private val commandQueue: Queue = PriorityQueue() private val activeCooldownGroups: MutableSet = ConcurrentHashMap.newKeySet() - /** Sequence id of the command the client sent most recently, which server-started commands continue from. */ - var lastClientCounter: Int = 0 - - @Synchronized - fun nextServerCounter(): Int { - return ++lastClientCounter - } + private var counter: Int = 0 @Synchronized fun executeNextCommand() { @@ -186,7 +165,10 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec } @Synchronized - fun queueCommand(command: EnqueuedCommand) { + fun queueCommand(qci: QueueCommandIntent) { + counter = if (qci.counter != 0) qci.counter else counter + 1 + + val command = EnqueuedCommand(qci.source, qci.command, qci.target, qci.arguments, counter) val rootCommand: Command = command.command if (rootCommand.cooldownGroup.isBlank()) { From 97c0ee4952cad0956b807b13fe060c2a45788300 Mon Sep 17 00:00:00 2001 From: Mads Boddum Date: Tue, 8 Sep 2026 02:38:33 +0200 Subject: [PATCH 5/6] Let the client enqueue the auto attack --- .../gameplay/combat/AutoAttackService.kt | 31 ++++++------------- .../global/commands/CommandQueueService.kt | 9 ++---- 2 files changed, 11 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt index f9607d9d6..2d96eccae 100644 --- a/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt +++ b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt @@ -25,7 +25,7 @@ ***********************************************************************************/ package com.projectswg.holocore.services.gameplay.combat -import com.projectswg.common.data.encodables.tangible.Posture +import com.projectswg.common.network.packets.swg.zone.ExecuteConsoleCommand import com.projectswg.holocore.intents.gameplay.combat.CombatCommandFailedIntent import com.projectswg.holocore.intents.gameplay.combat.DefaultActionIntent import com.projectswg.holocore.intents.gameplay.combat.ExitCombatIntent @@ -33,11 +33,9 @@ import com.projectswg.holocore.intents.support.global.command.QueueCommandIntent import com.projectswg.holocore.intents.support.global.zone.PlayerEventIntent import com.projectswg.holocore.resources.gameplay.combat.CombatStatus import com.projectswg.holocore.resources.support.data.server_info.StandardLog -import com.projectswg.holocore.resources.support.data.server_info.loader.ServerData import com.projectswg.holocore.resources.support.global.player.PlayerEvent import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureObject import com.projectswg.holocore.resources.support.objects.swg.weapon.WeaponObject -import com.projectswg.holocore.services.support.objects.ObjectStorageService.ObjectLookup import com.projectswg.holocore.utilities.HolocoreCoroutine import com.projectswg.holocore.utilities.cancelAndWait import com.projectswg.holocore.utilities.launchWithFixedRate @@ -87,7 +85,7 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv @IntentHandler private fun handleCombatCommandFailedIntent(ccfi: CombatCommandFailedIntent) { if (ccfi.status == CombatStatus.TOO_TIRED) { - StandardLog.onPlayerTrace(this, ccfi.source, "paused their default action") + StandardLog.onPlayerTrace(this, ccfi.source, "stopped their default action, too tired") stopAttacking(ccfi.source) } } @@ -95,7 +93,7 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv @IntentHandler private fun handleQueueCommandIntent(qci: QueueCommandIntent) { if (qci.counter != 0) { - startAttacking(qci.source) // a command the client sent itself means the player is attacking again + startAttacking(qci.source) } } @@ -136,30 +134,19 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv continue } - val command = ServerData.commands.getCommand(creature.defaultAttack ?: continue) ?: continue + val defaultAttack = creature.defaultAttack ?: continue val weapon = creature.equippedWeapon ?: continue - val target = findTarget(creature) ?: continue + val owner = creature.owner ?: continue nextAttackTimes[creature] = now + attackDelay(creature, weapon) - QueueCommandIntent(creature, target, "", command, 0).broadcast() + + val executeConsoleCommand = ExecuteConsoleCommand() + executeConsoleCommand.addCommand(defaultAttack) + owner.sendPacket(executeConsoleCommand) } } private fun attackDelay(creature: CreatureObject, weapon: WeaponObject): Long { return (weapon.getModdedWeaponAttackSpeedWithCap(creature) * 1E9).toLong() } - - private fun findTarget(creature: CreatureObject): CreatureObject? { - val lookAtTarget = ObjectLookup.getObjectById(creature.lookAtTargetId) as? CreatureObject ?: return null - - return if (isValidTarget(creature, lookAtTarget)) lookAtTarget else null - } - - private fun isValidTarget(creature: CreatureObject, target: CreatureObject): Boolean { - if (target.posture == Posture.INCAPACITATED || target.posture == Posture.DEAD) { - return false - } - - return target.isAttackable(creature) - } } diff --git a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt index e79597dcd..57acd587a 100644 --- a/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt +++ b/src/main/java/com/projectswg/holocore/services/support/global/commands/CommandQueueService.kt @@ -119,7 +119,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec @IntentHandler private fun handleQueueCommandIntent(qci: QueueCommandIntent) { - getQueue(qci.source).queueCommand(qci) + getQueue(qci.source).queueCommand(EnqueuedCommand(qci.source, qci.command, qci.target, qci.arguments, qci.counter)) } @IntentHandler @@ -141,8 +141,6 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec private val commandQueue: Queue = PriorityQueue() private val activeCooldownGroups: MutableSet = ConcurrentHashMap.newKeySet() - private var counter: Int = 0 - @Synchronized fun executeNextCommand() { val peek = commandQueue.peek() ?: return @@ -165,10 +163,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec } @Synchronized - fun queueCommand(qci: QueueCommandIntent) { - counter = if (qci.counter != 0) qci.counter else counter + 1 - - val command = EnqueuedCommand(qci.source, qci.command, qci.target, qci.arguments, counter) + fun queueCommand(command: EnqueuedCommand) { val rootCommand: Command = command.command if (rootCommand.cooldownGroup.isBlank()) { From eed7cf8d46b21ad57b13bbed7db6642f206eb325 Mon Sep 17 00:00:00 2001 From: Mads Boddum Date: Tue, 8 Sep 2026 03:25:16 +0200 Subject: [PATCH 6/6] Repeat the auto attack in a job per creature --- .../gameplay/combat/AutoAttackService.kt | 69 +++++++++---------- 1 file changed, 32 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt index 2d96eccae..4e6de7374 100644 --- a/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt +++ b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt @@ -28,8 +28,8 @@ package com.projectswg.holocore.services.gameplay.combat import com.projectswg.common.network.packets.swg.zone.ExecuteConsoleCommand import com.projectswg.holocore.intents.gameplay.combat.CombatCommandFailedIntent import com.projectswg.holocore.intents.gameplay.combat.DefaultActionIntent +import com.projectswg.holocore.intents.gameplay.combat.EnterCombatIntent import com.projectswg.holocore.intents.gameplay.combat.ExitCombatIntent -import com.projectswg.holocore.intents.support.global.command.QueueCommandIntent import com.projectswg.holocore.intents.support.global.zone.PlayerEventIntent import com.projectswg.holocore.resources.gameplay.combat.CombatStatus import com.projectswg.holocore.resources.support.data.server_info.StandardLog @@ -38,7 +38,9 @@ import com.projectswg.holocore.resources.support.objects.swg.creature.CreatureOb import com.projectswg.holocore.resources.support.objects.swg.weapon.WeaponObject import com.projectswg.holocore.utilities.HolocoreCoroutine import com.projectswg.holocore.utilities.cancelAndWait -import com.projectswg.holocore.utilities.launchWithFixedRate +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import me.joshlarson.jlcommon.control.IntentHandler import me.joshlarson.jlcommon.control.Service import java.util.concurrent.ConcurrentHashMap @@ -50,17 +52,12 @@ import java.util.concurrent.ConcurrentHashMap * The choice itself lives on the [CreatureObject], because the client only sends it when a toolbar * slot is ctrl-clicked or when it starts up. */ -class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Service() { +class AutoAttackService : Service() { - /** Creatures currently auto-attacking, mapped to the [System.nanoTime] their next attack is due. */ - private val nextAttackTimes: MutableMap = ConcurrentHashMap() + /** The job repeating the attack, for each creature currently auto-attacking. */ + private val attackJobs: MutableMap = ConcurrentHashMap() private val coroutineScope = HolocoreCoroutine.childScope() - override fun initialize(): Boolean { - coroutineScope.launchWithFixedRate(delayBetweenAttackChecks) { attemptAttacks() } - return true - } - override fun terminate(): Boolean { coroutineScope.cancelAndWait() return super.terminate() @@ -74,11 +71,11 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv if (command == null) { StandardLog.onPlayerTrace(this, creature, "cleared their default action") creature.defaultAttack = null - stopAttacking(creature) + cancelAttackJob(creature) } else { StandardLog.onPlayerTrace(this, creature, "set their default action to %s", command.name) creature.defaultAttack = command.name - startAttacking(creature) + startAttackJob(creature) } } @@ -86,67 +83,65 @@ class AutoAttackService(private val delayBetweenAttackChecks: Long = 100) : Serv private fun handleCombatCommandFailedIntent(ccfi: CombatCommandFailedIntent) { if (ccfi.status == CombatStatus.TOO_TIRED) { StandardLog.onPlayerTrace(this, ccfi.source, "stopped their default action, too tired") - stopAttacking(ccfi.source) + cancelAttackJob(ccfi.source) } } @IntentHandler - private fun handleQueueCommandIntent(qci: QueueCommandIntent) { - if (qci.counter != 0) { - startAttacking(qci.source) - } + private fun handleEnterCombatIntent(eci: EnterCombatIntent) { + startAttackJob(eci.source as? CreatureObject) } @IntentHandler private fun handleExitCombatIntent(eci: ExitCombatIntent) { - stopAttacking(eci.source as? CreatureObject) + cancelAttackJob(eci.source as? CreatureObject) } @IntentHandler private fun handlePlayerEventIntent(pei: PlayerEventIntent) { when (pei.event) { - PlayerEvent.PE_LOGGED_OUT, PlayerEvent.PE_DESTROYED -> stopAttacking(pei.player.creatureObject) + PlayerEvent.PE_LOGGED_OUT, PlayerEvent.PE_DESTROYED -> cancelAttackJob(pei.player.creatureObject) else -> {} } } - private fun startAttacking(creature: CreatureObject?) { + private fun startAttackJob(creature: CreatureObject?) { if (creature?.defaultAttack == null) { return } - nextAttackTimes.putIfAbsent(creature, System.nanoTime()) + synchronized(attackJobs) { + if (attackJobs[creature]?.isActive == true) { + return + } + + attackJobs[creature] = coroutineScope.launch { requestAttacksWhileInCombat(creature) } + } } - private fun stopAttacking(creature: CreatureObject?) { + private fun cancelAttackJob(creature: CreatureObject?) { if (creature == null) { return } - nextAttackTimes.remove(creature) + attackJobs.remove(creature)?.cancel() } - private fun attemptAttacks() { - val now = System.nanoTime() - - for ((creature, nextAttackTime) in nextAttackTimes) { - if (!creature.isInCombat || now < nextAttackTime) { - continue - } - - val defaultAttack = creature.defaultAttack ?: continue - val weapon = creature.equippedWeapon ?: continue - val owner = creature.owner ?: continue - - nextAttackTimes[creature] = now + attackDelay(creature, weapon) + private suspend fun requestAttacksWhileInCombat(creature: CreatureObject) { + while (creature.isInCombat) { + val defaultAttack = creature.defaultAttack ?: break + val weapon = creature.equippedWeapon ?: break + val owner = creature.owner ?: break val executeConsoleCommand = ExecuteConsoleCommand() executeConsoleCommand.addCommand(defaultAttack) owner.sendPacket(executeConsoleCommand) + + delay(attackDelay(creature, weapon)) } } private fun attackDelay(creature: CreatureObject, weapon: WeaponObject): Long { - return (weapon.getModdedWeaponAttackSpeedWithCap(creature) * 1E9).toLong() + return (weapon.getModdedWeaponAttackSpeedWithCap(creature) * 1000).toLong() } }