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..024fa3c81 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 @@ -36,6 +36,7 @@ import com.projectswg.holocore.intents.support.global.command.ExecuteCommandInte import com.projectswg.holocore.intents.support.global.command.QueueCommandIntent import com.projectswg.holocore.intents.support.global.network.InboundPacketIntent import com.projectswg.holocore.intents.support.global.zone.PlayerEventIntent +import com.projectswg.holocore.intents.support.objects.MoveObjectIntent import com.projectswg.holocore.resources.gameplay.combat.CombatStatus import com.projectswg.holocore.resources.support.color.SWGColor.Whites.white import com.projectswg.holocore.resources.support.data.server_info.StandardLog @@ -58,6 +59,7 @@ import com.projectswg.holocore.utilities.HolocoreCoroutine import com.projectswg.holocore.utilities.cancelAndWait import com.projectswg.holocore.utilities.launchAfter import com.projectswg.holocore.utilities.launchWithFixedRate +import kotlinx.coroutines.Job import me.joshlarson.jlcommon.control.IntentHandler import me.joshlarson.jlcommon.control.Service import me.joshlarson.jlcommon.log.Log @@ -121,6 +123,15 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec getQueue(qci.source).queueCommand(EnqueuedCommand(qci.source, qci.command, qci.target, qci.arguments, qci.counter)) } + @IntentHandler + private fun handleMoveObjectIntent(moi: MoveObjectIntent) { + // A speed of zero means the creature is turning in place, which should not interrupt a warmup + if (moi.speed <= 0) return + val creature = moi.obj as? CreatureObject ?: return + + combatQueueMap[creature]?.cancelWarmup() + } + @IntentHandler private fun handleExitCombatIntent(eci: ExitCombatIntent) { if (eci.source is CreatureObject) { @@ -139,6 +150,7 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec private inner class CreatureCombatQueue { private val commandQueue: Queue = PriorityQueue() private val activeCooldownGroups: MutableSet = ConcurrentHashMap.newKeySet() + private var warmup: Warmup? = null @Synchronized fun executeNextCommand() { @@ -197,14 +209,34 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec command.source.sendSelf(warmupTimer) - coroutineScope.launchAfter((warmupTime * 1000).toLong()) { - executeCommandNow(command) + val job = coroutineScope.launchAfter((warmupTime * 1000).toLong()) { + finishWarmup(command) } + warmup = Warmup(command, job) } else { executeCommandNow(command) } } + @Synchronized + private fun finishWarmup(command: EnqueuedCommand) { + // The warmup can be cancelled while this coroutine waits for the lock + if (warmup?.command !== command) return + warmup = null + + executeCommandNow(command) + } + + @Synchronized + fun cancelWarmup() { + val cancelled = warmup ?: return + warmup = null + cancelled.job.cancel() + + sendQueueRemove(cancelled.command, CheckCommandResult(CommandQueueDequeue.ErrorCode.CANCELLED, 0)) + sendCommandFailed(cancelled.command) + } + private fun executeCommandNow(command: EnqueuedCommand) { val rootCommand: Command = command.command val source: CreatureObject = command.source @@ -394,6 +426,8 @@ class CommandQueueService @JvmOverloads constructor(private val delayBetweenChec } } + private class Warmup(val command: EnqueuedCommand, val job: Job) + private class EnqueuedCommand(val source: CreatureObject, val command: Command, val target: SWGObject?, val arguments: String, val counter: Int) : Comparable { override fun compareTo(other: EnqueuedCommand): Int { return command.defaultPriority.compareTo(other.command.defaultPriority) diff --git a/src/test/java/com/projectswg/holocore/services/gameplay/combat/WarmupTest.kt b/src/test/java/com/projectswg/holocore/services/gameplay/combat/WarmupTest.kt new file mode 100644 index 000000000..257d9e557 --- /dev/null +++ b/src/test/java/com/projectswg/holocore/services/gameplay/combat/WarmupTest.kt @@ -0,0 +1,104 @@ +/*********************************************************************************** + * 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.CRC +import com.projectswg.common.network.packets.swg.zone.object_controller.CommandQueueEnqueue +import com.projectswg.common.network.packets.swg.zone.object_controller.CommandTimer +import com.projectswg.holocore.intents.support.global.network.InboundPacketIntent +import com.projectswg.holocore.intents.support.objects.MoveObjectIntent +import com.projectswg.holocore.intents.support.objects.ObjectCreatedIntent +import com.projectswg.holocore.resources.support.objects.ObjectCreator +import com.projectswg.holocore.resources.support.objects.swg.weapon.DefaultWeaponFactory +import com.projectswg.holocore.services.support.global.commands.CommandExecutionService +import com.projectswg.holocore.services.support.global.commands.CommandQueueService +import com.projectswg.holocore.test.resources.GenericCreatureObject +import com.projectswg.holocore.test.runners.TestRunnerSimulatedWorld +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.util.concurrent.TimeUnit + +/** + * A command with a warmup time is used, so there is a window where the warmup can be interrupted. + */ +class WarmupTest : TestRunnerSimulatedWorld() { + + @BeforeEach + fun setup() { + registerService(CommandQueueService(5)) + registerService(CommandExecutionService()) + } + + @Test + fun `moving interrupts the warmup`() { + val creatureObject = createCreatureObject() + startWarmup(creatureObject) + + MoveObjectIntent(creatureObject, creatureObject.location, RUN_SPEED).broadcast() + waitForIntents() + + assertNotNull(waitForCommandTimerWithFlag(creatureObject, CommandTimer.CommandTimerFlag.FAILED), "Warmup was not interrupted") + } + + @Test + fun `turning in place does not interrupt the warmup`() { + val creatureObject = createCreatureObject() + startWarmup(creatureObject) + + MoveObjectIntent(creatureObject, creatureObject.location, 0.0).broadcast() + waitForIntents() + + assertNull(waitForCommandTimerWithFlag(creatureObject, CommandTimer.CommandTimerFlag.FAILED), "Warmup was interrupted") + } + + private fun startWarmup(creatureObject: GenericCreatureObject) { + val player = creatureObject.owner ?: throw RuntimeException("Unable to access player") + val crc = CRC.getCrc("coupdegrace") + InboundPacketIntent(player, CommandQueueEnqueue(creatureObject.objectId, 0, crc, 0, "")).broadcast() + + assertNotNull(waitForCommandTimerWithFlag(creatureObject, CommandTimer.CommandTimerFlag.WARMUP), "Warmup never started") + } + + private fun waitForCommandTimerWithFlag(creatureObject: GenericCreatureObject, flag: CommandTimer.CommandTimerFlag): CommandTimer? { + val player = creatureObject.owner ?: throw RuntimeException("Unable to access player") + return player.waitForNextPacket(CommandTimer::class.java, 500, TimeUnit.MILLISECONDS) { it.flags.contains(flag) } + } + + private fun createCreatureObject(): GenericCreatureObject { + val creatureObject = GenericCreatureObject(ObjectCreator.getNextObjectId()) + ObjectCreatedIntent(creatureObject).broadcast() + val defaultWeapon = DefaultWeaponFactory.createDefaultWeapon() + defaultWeapon.moveToContainer(creatureObject) + creatureObject.equippedWeapon = defaultWeapon + return creatureObject + } + + private companion object { + private const val RUN_SPEED = 5.376 + } +}