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..4e6de7374
--- /dev/null
+++ b/src/main/java/com/projectswg/holocore/services/gameplay/combat/AutoAttackService.kt
@@ -0,0 +1,147 @@
+/***********************************************************************************
+ * 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.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.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.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.utilities.HolocoreCoroutine
+import com.projectswg.holocore.utilities.cancelAndWait
+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
+
+/**
+ * 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 : Service() {
+
+ /** The job repeating the attack, for each creature currently auto-attacking. */
+ private val attackJobs: MutableMap = ConcurrentHashMap()
+ private val coroutineScope = HolocoreCoroutine.childScope()
+
+ 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
+ cancelAttackJob(creature)
+ } else {
+ StandardLog.onPlayerTrace(this, creature, "set their default action to %s", command.name)
+ creature.defaultAttack = command.name
+ startAttackJob(creature)
+ }
+ }
+
+ @IntentHandler
+ private fun handleCombatCommandFailedIntent(ccfi: CombatCommandFailedIntent) {
+ if (ccfi.status == CombatStatus.TOO_TIRED) {
+ StandardLog.onPlayerTrace(this, ccfi.source, "stopped their default action, too tired")
+ cancelAttackJob(ccfi.source)
+ }
+ }
+
+ @IntentHandler
+ private fun handleEnterCombatIntent(eci: EnterCombatIntent) {
+ startAttackJob(eci.source as? CreatureObject)
+ }
+
+ @IntentHandler
+ private fun handleExitCombatIntent(eci: ExitCombatIntent) {
+ cancelAttackJob(eci.source as? CreatureObject)
+ }
+
+ @IntentHandler
+ private fun handlePlayerEventIntent(pei: PlayerEventIntent) {
+ when (pei.event) {
+ PlayerEvent.PE_LOGGED_OUT, PlayerEvent.PE_DESTROYED -> cancelAttackJob(pei.player.creatureObject)
+ else -> {}
+ }
+ }
+
+ private fun startAttackJob(creature: CreatureObject?) {
+ if (creature?.defaultAttack == null) {
+ return
+ }
+
+ synchronized(attackJobs) {
+ if (attackJobs[creature]?.isActive == true) {
+ return
+ }
+
+ attackJobs[creature] = coroutineScope.launch { requestAttacksWhileInCombat(creature) }
+ }
+ }
+
+ private fun cancelAttackJob(creature: CreatureObject?) {
+ if (creature == null) {
+ return
+ }
+
+ attackJobs.remove(creature)?.cancel()
+ }
+
+ 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) * 1000).toLong()
+ }
+}
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..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
@@ -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
@@ -225,6 +226,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec
handleStatus(source, combatCommand, combatStatus)
if (combatStatus != CombatStatus.SUCCESS) {
+ CombatCommandFailedIntent(source, combatStatus).broadcast()
sendCommandFailed(command)
return
}