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 }