From 248a7d97d956adbdb505a59aeb03b3ff5aa1d8a4 Mon Sep 17 00:00:00 2001 From: Gary Tierney Date: Sun, 19 Aug 2018 22:28:41 +0100 Subject: [PATCH] Update plugin test framework to junit5 Updates the testing infrastructure to use the latest relesae of junit and leverages the new extension mechanism to create an easy to use testing framework. Also adds additional test coverage for several plugins. --- game/plugin-testing/build.gradle | 10 +- .../game/plugin/testing/KotlinPluginTest.kt | 34 ---- .../plugin/testing/KotlinPluginTestHelpers.kt | 121 --------------- .../testing/assertions/actionAsserts.kt | 21 +++ .../testing/assertions/stringAsserts.kt | 7 + .../testing/fakes/FakePluginContextFactory.kt | 20 ++- .../testing/junit/ApolloTestExtension.kt | 127 +++++++++++++++ .../plugin/testing/junit/ApolloTestState.kt | 72 +++++++++ .../plugin/testing/junit/api/ActionCapture.kt | 94 +++++++++++ .../junit/api/ActionCaptureCallback.kt | 7 + .../api/ActionCaptureCallbackRegistration.kt | 5 + .../testing/junit/api/ActionCaptureDelay.kt | 6 + .../api/annotations/definitionAnnotations.kt | 5 + .../junit/api/annotations/stubAnnotations.kt | 8 + .../junit/api/annotations/testAnnotations.kt | 6 + .../testing/junit/api/interactions/player.kt | 42 +++++ .../testing/junit/api/interactions/world.kt | 35 +++++ .../testing/junit/mocking/StubPrototype.kt | 5 + .../testing/junit/stubs/GameObjectStubInfo.kt | 2 + .../plugin/testing/junit/stubs/NpcStubInfo.kt | 2 + .../testing/junit/stubs/PlayerStubInfo.kt | 25 +++ .../testing/mockito/KotlinArgMatcher.kt | 25 --- .../mockito/KotlinMockitoExtensions.kt | 14 -- game/plugin/api/src/player.kt | 24 ++- game/plugin/api/src/util.kt | 4 +- game/plugin/api/test/NamedLookupTests.kt | 17 -- game/plugin/bank/test/OpenBankTest.kt | 35 +++-- game/plugin/build.gradle | 15 ++ .../consumables/test/FoodOrDrinkTests.kt | 89 ++++++----- game/plugin/dummy/test/TrainingDummyTest.kt | 57 ++++--- game/plugin/logout/test/LogoutTests.kt | 26 ++-- game/plugin/skills/fishing/src/fishing.kt | 33 ++-- .../skills/fishing/src/fishing.plugin.kts | 2 +- game/plugin/skills/mining/build.gradle | 2 +- game/plugin/skills/mining/src/gem.kt | 2 +- game/plugin/skills/mining/src/mining.kt | 146 ++++++++++++++++++ .../skills/mining/src/mining.plugin.kts | 107 ------------- game/plugin/skills/mining/src/ore.kt | 10 +- game/plugin/skills/mining/src/pickaxe.kt | 2 +- .../skills/mining/test/MiningActionTests.kt | 80 ++++++++++ .../plugin/skills/mining/test/PickaxeTests.kt | 75 +++++++++ .../skills/mining/test/ProspectingTests.kt | 46 ++++++ game/plugin/skills/mining/test/TestData.kt | 17 ++ .../skills/prayer/src/BoneBuryAction.kt | 2 +- .../skills/prayer/src/prayer.plugin.kts | 2 +- .../skills/prayer/test/BuryBoneTests.kt | 72 +++++++++ .../woodcutting/src/woodcutting.plugin.kts | 7 +- .../skills/woodcutting/test/AxeTests.kt | 72 +++++++++ .../skills/woodcutting/test/TestData.kt | 17 ++ .../woodcutting/test/WoodcuttingTests.kt | 88 +++++++++++ game/plugin/util/lookup/build.gradle | 3 + .../java/org/apollo/game/model/World.java | 4 +- .../org/apollo/game/model/entity/Mob.java | 18 +++ .../org/apollo/game/action/ActionCoroutine.kt | 13 +- .../apollo/game/action/ActionCoroutineTest.kt | 5 +- gradle/properties.gradle | 5 + 56 files changed, 1327 insertions(+), 463 deletions(-) delete mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTest.kt delete mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTestHelpers.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/actionAsserts.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/stringAsserts.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestExtension.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestState.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCapture.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallback.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallbackRegistration.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureDelay.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/definitionAnnotations.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/stubAnnotations.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/testAnnotations.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/player.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/world.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/mocking/StubPrototype.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/GameObjectStubInfo.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/NpcStubInfo.kt create mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/PlayerStubInfo.kt delete mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinArgMatcher.kt delete mode 100644 game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinMockitoExtensions.kt delete mode 100644 game/plugin/api/test/NamedLookupTests.kt create mode 100644 game/plugin/skills/mining/src/mining.kt create mode 100644 game/plugin/skills/mining/test/MiningActionTests.kt create mode 100644 game/plugin/skills/mining/test/PickaxeTests.kt create mode 100644 game/plugin/skills/mining/test/ProspectingTests.kt create mode 100644 game/plugin/skills/mining/test/TestData.kt create mode 100644 game/plugin/skills/prayer/test/BuryBoneTests.kt create mode 100644 game/plugin/skills/woodcutting/test/AxeTests.kt create mode 100644 game/plugin/skills/woodcutting/test/TestData.kt create mode 100644 game/plugin/skills/woodcutting/test/WoodcuttingTests.kt create mode 100644 game/plugin/util/lookup/build.gradle diff --git a/game/plugin-testing/build.gradle b/game/plugin-testing/build.gradle index 4bac9513..2568f3a6 100644 --- a/game/plugin-testing/build.gradle +++ b/game/plugin-testing/build.gradle @@ -5,9 +5,15 @@ dependencies { api project(':game') api project(':net') - api group: 'junit', name: 'junit', version: junitVersion - api group: 'org.powermock', name: 'powermock-api-mockito', version: powermockVersion + // JUnit Jupiter API and TestEngine implementation + api("org.junit.jupiter:junit-jupiter-api:${junitJupiterVersion}") + api("org.junit.jupiter:junit-jupiter-params:${junitJupiterVersion}") + implementation("org.junit.jupiter:junit-jupiter-engine:${junitJupiterVersion}") + implementation("org.junit.platform:junit-platform-launcher:${junitPlatformVersion}") + + api group: 'io.mockk', name: 'mockk', version: mockkVersion api group: 'org.assertj', name: 'assertj-core', version: assertjVersion + api group: 'com.willowtreeapps.assertk', name: 'assertk', version: assertkVersion implementation group: 'org.powermock', name: 'powermock-module-junit4', version: powermockVersion } \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTest.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTest.kt deleted file mode 100644 index 08fecc64..00000000 --- a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.apollo.game.plugin.testing - -import org.apollo.game.message.handler.MessageHandlerChainSet -import org.apollo.game.model.World -import org.apollo.game.model.entity.Player -import org.apollo.game.plugin.* -import org.apollo.game.plugin.testing.fakes.FakePluginContextFactory -import org.junit.Before -import org.junit.runner.RunWith -import org.powermock.api.mockito.PowerMockito -import org.powermock.core.classloader.annotations.PrepareForTest -import org.powermock.modules.junit4.PowerMockRunner -import java.util.* - -@RunWith(PowerMockRunner::class) -@PrepareForTest(World::class, PluginContext::class, Player::class) -abstract class KotlinPluginTest: KotlinPluginTestHelpers() { - override lateinit var world: World - override lateinit var player: Player - override lateinit var messageHandlers: MessageHandlerChainSet - - @Before - open fun setup() { - messageHandlers = MessageHandlerChainSet() - world = PowerMockito.spy(World()) - - val pluginEnvironment = KotlinPluginEnvironment(world) - pluginEnvironment.setContext(FakePluginContextFactory.create(messageHandlers)) - pluginEnvironment.load(ArrayList()) - - player = world.spawnPlayer("testPlayer") - } - -} diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTestHelpers.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTestHelpers.kt deleted file mode 100644 index 9dd4898a..00000000 --- a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/KotlinPluginTestHelpers.kt +++ /dev/null @@ -1,121 +0,0 @@ -package org.apollo.game.plugin.testing - -import org.apollo.cache.def.ItemDefinition -import org.apollo.cache.def.NpcDefinition -import org.apollo.game.action.Action -import org.apollo.game.message.handler.MessageHandlerChainSet -import org.apollo.game.message.impl.* -import org.apollo.game.model.* -import org.apollo.game.model.entity.* -import org.apollo.game.model.entity.obj.GameObject -import org.apollo.game.model.entity.obj.StaticGameObject -import org.apollo.net.message.Message -import org.apollo.util.security.PlayerCredentials -import org.junit.Assert -import org.mockito.* -import org.powermock.api.mockito.PowerMockito - -/** - * A base class containing a set of helper methods to be used within plugin tests. - */ -abstract class KotlinPluginTestHelpers { - abstract var world: World - abstract var player: Player - abstract var messageHandlers: MessageHandlerChainSet - - /** - * Waits for an [Action] to complete within a specified number of pulses, and with an optional predicate - * to test the [Action] against. - */ - fun Player.waitForActionCompletion(predicate: (Action) -> Boolean = { _ -> true }, timeout: Int = 15) { - val actionCaptor: ArgumentCaptor> = ArgumentCaptor.forClass(Action::class.java) - Mockito.verify(this).startAction(actionCaptor.capture()) - - val action: Action = actionCaptor.value as Action - Assert.assertTrue("Found wrong action type", predicate.invoke(action)) - - var pulses = 0 - - do { - action.pulse() - - /** - * Introducing an artificial delay is necessary to prevent the timeout being exceeded before - * an asynchronous [Action] really starts. When a job is submitted to a new coroutine context - * there may be a delay before it is actually executed. - * - * This delay is typically sub-millisecond and is only incurred with startup. Since game actions - * have larger delays of their own this isn't a problem in practice. - */ - Thread.sleep(50L) - } while (action.isRunning && pulses++ < timeout) - - Assert.assertFalse("Exceeded timeout waiting for action completion", pulses > timeout) - } - - /** - * Spawns a new NPC with the minimum set of dependencies required to function correctly in the world. - */ - fun World.spawnNpc(id: Int, position: Position): Npc { - val definition = NpcDefinition(id) - val npc = Npc(this, position, definition, arrayOfNulls(4)) - val region = regionRepository.fromPosition(position) - val npcs = npcRepository - - npcs.add(npc) - region.addEntity(npc) - - return npc - } - - /** - * Spawn a new player stub in the world, with a dummy game session. - */ - fun World.spawnPlayer(username: String, position: Position = Position(3200, 3200, 0)): Player { - val credentials = PlayerCredentials(username, "test", 1, 1, "0.0.0.0") - val region = regionRepository.fromPosition(position) - - val player = PowerMockito.spy(Player(this, credentials, position)) - register(player) - region.addEntity(player) - - PowerMockito.doNothing().`when`(player).send(Matchers.any()) - - return player - } - - /** - * Spawn a new static game object into the world with the given id and position. - */ - fun World.spawnObject(id: Int, position: Position): GameObject { - val obj = StaticGameObject(this, id, position, 0, 0) - - spawn(obj) - - return obj - } - - /** - * Fake a client [Message] originating from a player and send it to the relevant - * message handlers. - */ - fun Player.notify(message: Message) { - messageHandlers.notify(this, message) - } - - /** - * Move the player within interaction distance to the given [Entity] and fake an action - * message. - */ - fun Player.interactWith(entity: Entity, option: Int = 1) { - position = entity.position.step(1, Direction.NORTH) - - when (entity) { - is GameObject -> notify(ObjectActionMessage(option, entity.id, entity.position)) - is Npc -> notify(NpcActionMessage(option, entity.index)) - is Player -> notify(PlayerActionMessage(option, entity.index)) - } - } - -} - diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/actionAsserts.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/actionAsserts.kt new file mode 100644 index 00000000..7433a033 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/actionAsserts.kt @@ -0,0 +1,21 @@ +package org.apollo.game.plugin.testing.assertions + +import io.mockk.MockKVerificationScope +import io.mockk.verify +import org.apollo.game.plugin.testing.junit.api.ActionCaptureCallbackRegistration + + +/** + * Verify some expectations on a [mock] after a delayed event (specified by [DelayMode]). + */ +fun verifyAfter(registration: ActionCaptureCallbackRegistration, description: String? = null, verifier: MockKVerificationScope.() -> Unit) { + after(registration, description) { verify(verifyBlock = verifier) } +} + +/** + * Run a [callback] after a given delay, specified by [DelayMode]. + */ +fun after(registration: ActionCaptureCallbackRegistration, description: String? = null, callback: () -> Unit) { + registration.function = callback + registration.description = description +} \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/stringAsserts.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/stringAsserts.kt new file mode 100644 index 00000000..a21d4a49 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/assertions/stringAsserts.kt @@ -0,0 +1,7 @@ +package org.apollo.game.plugin.testing.assertions + +import io.mockk.MockKMatcherScope + +inline fun MockKMatcherScope.contains(search: String) = match { it.contains(search) } +inline fun MockKMatcherScope.startsWith(search: String) = match { it.startsWith(search) } +inline fun MockKMatcherScope.endsWith(search: String) = match { it.endsWith(search) } \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/fakes/FakePluginContextFactory.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/fakes/FakePluginContextFactory.kt index aac82329..70190047 100644 --- a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/fakes/FakePluginContextFactory.kt +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/fakes/FakePluginContextFactory.kt @@ -1,21 +1,25 @@ package org.apollo.game.plugin.testing.fakes +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot import org.apollo.game.message.handler.MessageHandler import org.apollo.game.message.handler.MessageHandlerChainSet import org.apollo.game.plugin.PluginContext import org.apollo.net.message.Message -import org.mockito.invocation.InvocationOnMock -import org.mockito.stubbing.Answer -import org.powermock.api.mockito.PowerMockito object FakePluginContextFactory { fun create(messageHandlers: MessageHandlerChainSet): PluginContext { - val answer = Answer { invocation: InvocationOnMock -> - messageHandlers.putHandler( - invocation.arguments[0] as Class, - invocation.arguments[1] as MessageHandler<*>) + val ctx = mockk() + val typeCapture = slot>() + val handlerCapture = slot>() + + every { + ctx.addMessageHandler(capture(typeCapture), capture(handlerCapture)) + } answers { + messageHandlers.putHandler(typeCapture.captured, handlerCapture.captured) } - return PowerMockito.mock(PluginContext::class.java, answer) + return ctx } } \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestExtension.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestExtension.kt new file mode 100644 index 00000000..b3640465 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestExtension.kt @@ -0,0 +1,127 @@ +package org.apollo.game.plugin.testing.junit + +import io.mockk.every +import io.mockk.slot +import io.mockk.spyk +import io.mockk.staticMockk +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.message.handler.MessageHandlerChainSet +import org.apollo.game.model.World +import org.apollo.game.model.entity.Npc +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.obj.GameObject +import org.apollo.game.plugin.KotlinPluginEnvironment +import org.apollo.game.plugin.PluginMetaData +import org.apollo.game.plugin.testing.fakes.FakePluginContextFactory +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.apollo.game.plugin.testing.junit.mocking.StubPrototype +import org.junit.jupiter.api.extension.* +import java.util.* +import kotlin.reflect.KMutableProperty +import kotlin.reflect.full.createType +import kotlin.reflect.full.declaredMemberFunctions +import kotlin.reflect.full.declaredMemberProperties +import kotlin.reflect.full.findAnnotation +import kotlin.reflect.jvm.jvmErasure + +internal val supportedTestDoubleTypes = setOf( + Player::class.createType(), + Npc::class.createType(), + GameObject::class.createType(), + World::class.createType(), + ActionCapture::class.createType() +) + +class ApolloTestingExtension : + AfterTestExecutionCallback, + BeforeAllCallback, + AfterAllCallback, + BeforeEachCallback, + AfterEachCallback, + ParameterResolver { + + private val namespace = ExtensionContext.Namespace.create("apollo") + + private fun cleanup(context: ExtensionContext) { + val store = context.getStore(namespace) + val state = store.get(ApolloTestState::class) as ApolloTestState + + try { + state.actionCapture?.runAction() + } finally { + state.reset() + } + } + + override fun afterAll(context: ExtensionContext) { + val store = context.getStore(namespace) + store.remove(ApolloTestState::class) + } + + override fun afterEach(context: ExtensionContext) = cleanup(context) + + override fun afterTestExecution(context: ExtensionContext) = cleanup(context) + + override fun beforeAll(context: ExtensionContext) { + val stubHandlers = MessageHandlerChainSet() + val stubWorld = spyk(World()) + + val pluginEnvironment = KotlinPluginEnvironment(stubWorld) + pluginEnvironment.setContext(FakePluginContextFactory.create(stubHandlers)) + pluginEnvironment.load(ArrayList()) + + val state = ApolloTestState(stubHandlers, stubWorld) + val store = context.getStore(namespace) + store.put(ApolloTestState::class, state) + } + + override fun beforeEach(context: ExtensionContext) { + val testClass = context.requiredTestClass.kotlin + val testClassInstance = context.requiredTestInstance + val testClassProps = testClass.declaredMemberProperties + val testClassMethods = context.testClass.map { it.kotlin.declaredMemberFunctions }.orElse(emptyList()) + val testClassItemDefs = testClassMethods.asSequence() + .mapNotNull { it.findAnnotation()?.let { anno -> it to anno } } + .flatMap { (it.first.call(context.requiredTestInstance as Any) as Collection).asSequence() } + .map { it.id to it } + .toMap() + + if (testClassItemDefs.isNotEmpty()) { + val itemIdSlot = slot() + + staticMockk().mock() + every { ItemDefinition.lookup(capture(itemIdSlot)) } answers { testClassItemDefs[itemIdSlot.captured] } + } + + val store = context.getStore(namespace) + val state = store.get(ApolloTestState::class) as ApolloTestState + + val propertyStubSites = testClassProps.asSequence() + .mapNotNull { it as? KMutableProperty<*> } + .filter { supportedTestDoubleTypes.contains(it.returnType) } + + propertyStubSites.forEach { + it.setter.call( + testClassInstance, + state.createStub(StubPrototype(it.returnType.jvmErasure, it.annotations)) + ) + } + } + + override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean { + val param = parameterContext.parameter + val paramType = param.type.kotlin + + return supportedTestDoubleTypes.contains(paramType.createType()) + } + + override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any { + val param = parameterContext.parameter + val paramType = param.type.kotlin + val testStore = extensionContext.getStore(namespace) + val testState = testStore.get(ApolloTestState::class) as ApolloTestState + + return testState.createStub(StubPrototype(paramType, param.annotations.toList())) + } +} \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestState.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestState.kt new file mode 100644 index 00000000..a9e8c4f1 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/ApolloTestState.kt @@ -0,0 +1,72 @@ +package org.apollo.game.plugin.testing.junit + +import io.mockk.every +import io.mockk.slot +import io.mockk.spyk +import org.apollo.game.action.Action +import org.apollo.game.message.handler.MessageHandlerChainSet +import org.apollo.game.model.World +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.mocking.StubPrototype +import org.apollo.game.plugin.testing.junit.stubs.PlayerStubInfo +import org.apollo.net.message.Message +import org.apollo.util.security.PlayerCredentials +import kotlin.reflect.KClass + +data class ApolloTestState(val handlers: MessageHandlerChainSet, val world: World) { + val players = mutableListOf() + var actionCapture: ActionCapture? = null + + fun createActionCapture(type: KClass>): ActionCapture { + if (actionCapture != null) { + throw IllegalStateException("Cannot specify more than one ActionCapture") + } + + actionCapture = ActionCapture(type) + return actionCapture!! + } + + fun createStub(proto: StubPrototype): T { + val annotations = proto.annotations + + return when (proto.type) { + Player::class -> createPlayer(PlayerStubInfo.create(annotations)) as T + World::class -> world as T + ActionCapture::class -> createActionCapture(Action::class) as T + else -> throw IllegalArgumentException("Can't stub ${proto.type.qualifiedName}") + } + } + + fun createPlayer(info: PlayerStubInfo): Player { + val credentials = PlayerCredentials(info.name, "test", 1, 1, "0.0.0.0") + val region = world.regionRepository.fromPosition(info.position) + + val player = spyk(Player(world, credentials, info.position)) + + world.register(player) + region.addEntity(player) + players.add(player) + + val actionSlot = slot>() + val messageSlot = slot() + + every { player.send(capture(messageSlot)) } answers { handlers.notify(player, messageSlot.captured) } + every { player.startAction(capture(actionSlot)) } answers { + actionCapture?.capture(actionSlot.captured) + true + } + + return player + } + + fun reset() { + actionCapture = null + players.forEach { + it.stopAction() + world.unregister(it) + } + + players.clear() + } +} \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCapture.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCapture.kt new file mode 100644 index 00000000..9d0a1478 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCapture.kt @@ -0,0 +1,94 @@ +package org.apollo.game.plugin.testing.junit.api + +import org.apollo.game.action.Action +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import kotlin.reflect.KClass +import kotlin.reflect.full.isSuperclassOf + +class ActionCapture(val type: KClass>) { + private var action: Action<*>? = null + private val callbacks = mutableListOf() + private var lastTicks: Int = 0 + + fun capture(captured: Action<*>) { + assertTrue(type.isSuperclassOf(captured::class)) { + "${captured::class.simpleName} is not an instance of ${type.simpleName}" + } + + this.action = captured + } + + private fun callback(delay: ActionCaptureDelay): ActionCaptureCallbackRegistration { + val registration = ActionCaptureCallbackRegistration() + val callback = ActionCaptureCallback(delay, registration) + + callbacks.add(callback) + return registration + } + + fun runAction(timeout: Int = 50) { + action?.let { + var pulses = 0 + + do { + it.pulse() + pulses++ + + val tickCallbacks = callbacks.filter { it.delay == ActionCaptureDelay.Ticks(pulses) } + tickCallbacks.forEach { it.invoke() } + + callbacks.removeAll(tickCallbacks) + } while (it.isRunning && pulses < timeout) + + val completionCallbacks = callbacks.filter { it.delay == ActionCaptureDelay.Completed } + completionCallbacks.forEach { it.invoke() } + + callbacks.removeAll(completionCallbacks) + } + + assertEquals(0, callbacks.size, { + "untriggered callbacks:\n" + callbacks + .map { + val delayDescription = when (it.delay) { + is ActionCaptureDelay.Ticks -> "${it.delay.count} ticks" + is ActionCaptureDelay.Completed -> "action completion" + } + + "$delayDescription (${it.callbackRegistration.description ?: ""})" + } + .joinToString("\n") + .prependIndent(" ") + }) + } + + + /** + * Create a callback registration that triggers after exactly [count] ticks. + */ + fun exactTicks(count: Int) = callback(ActionCaptureDelay.Ticks(count)) + + /** + * Create a callback registration that triggers after [count] ticks. This method is cumulative, + * and will take into account previous calls to [ticks] when creating new callbacks. + * + * To run a callback after an exact number of ticks use [exactTicks]. + */ + fun ticks(count: Int): ActionCaptureCallbackRegistration { + lastTicks += count + + return exactTicks(lastTicks) + } + + /** + * Create a callback registration that triggers when an [Action] completes. + */ + fun complete() = callback(ActionCaptureDelay.Completed) + + /** + * Check if this capture has a pending [Action] to run. + */ + fun isPending(): Boolean { + return action?.isRunning ?: false + } +} \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallback.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallback.kt new file mode 100644 index 00000000..7f9f9b78 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallback.kt @@ -0,0 +1,7 @@ +package org.apollo.game.plugin.testing.junit.api + +data class ActionCaptureCallback(val delay: ActionCaptureDelay, val callbackRegistration: ActionCaptureCallbackRegistration) { + fun invoke() { + callbackRegistration.function?.invoke() + } +} \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallbackRegistration.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallbackRegistration.kt new file mode 100644 index 00000000..15d940f5 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureCallbackRegistration.kt @@ -0,0 +1,5 @@ +package org.apollo.game.plugin.testing.junit.api + +typealias Function = () -> Unit + +class ActionCaptureCallbackRegistration(var function: Function? = null, var description: String? = null) \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureDelay.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureDelay.kt new file mode 100644 index 00000000..f24ff8b6 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/ActionCaptureDelay.kt @@ -0,0 +1,6 @@ +package org.apollo.game.plugin.testing.junit.api + +sealed class ActionCaptureDelay { + data class Ticks(val count: Int) : ActionCaptureDelay() + object Completed : ActionCaptureDelay() +} diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/definitionAnnotations.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/definitionAnnotations.kt new file mode 100644 index 00000000..484d0b2c --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/definitionAnnotations.kt @@ -0,0 +1,5 @@ +package org.apollo.game.plugin.testing.junit.api.annotations + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ItemDefinitions \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/stubAnnotations.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/stubAnnotations.kt new file mode 100644 index 00000000..9166616f --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/stubAnnotations.kt @@ -0,0 +1,8 @@ +package org.apollo.game.plugin.testing.junit.api.annotations + +annotation class Id(val value: Int) +annotation class Pos(val x: Int, val y: Int, val height: Int = 0) + +@Target(AnnotationTarget.FIELD) +@Retention(AnnotationRetention.RUNTIME) +annotation class TestMock \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/testAnnotations.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/testAnnotations.kt new file mode 100644 index 00000000..03aa36a1 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/annotations/testAnnotations.kt @@ -0,0 +1,6 @@ +package org.apollo.game.plugin.testing.junit.api.annotations + +import org.apollo.game.action.Action +import kotlin.reflect.KClass + +annotation class ActionTest(val value: KClass> = Action::class) \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/player.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/player.kt new file mode 100644 index 00000000..6fffee19 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/player.kt @@ -0,0 +1,42 @@ +package org.apollo.game.plugin.testing.junit.api.interactions + +import org.apollo.game.message.impl.ItemOptionMessage +import org.apollo.game.message.impl.NpcActionMessage +import org.apollo.game.message.impl.ObjectActionMessage +import org.apollo.game.message.impl.PlayerActionMessage +import org.apollo.game.model.Direction +import org.apollo.game.model.Position +import org.apollo.game.model.entity.Entity +import org.apollo.game.model.entity.Npc +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.obj.GameObject + +/** + * Send an [ItemOptionMessage] for the given [id], [option], [slot], and [interfaceId], simulating a + * player interacting with an item. + */ +fun Player.interactWithItem(id: Int, option: Int, slot: Int? = null, interfaceId: Int? = null) { + send(ItemOptionMessage(option, interfaceId ?: -1, id, slot ?: inventory.slotOf(id))) +} + +/** + * Spawn a new object (defaulting to in-front of the player) and immediately interact with it. + */ +fun Player.interactWithObject(id: Int, option: Int, at: Position? = null) { + val obj = world.spawnObject(id, at ?: position.step(1, Direction.NORTH)) + interactWith(obj, option) +} + +/** + * Move the player within interaction distance to the given [Entity] and fake an action + * message. + */ +fun Player.interactWith(entity: Entity, option: Int = 1) { + position = entity.position.step(1, Direction.NORTH) + + when (entity) { + is GameObject -> send(ObjectActionMessage(option, entity.id, entity.position)) + is Npc -> send(NpcActionMessage(option, entity.index)) + is Player -> send(PlayerActionMessage(option, entity.index)) + } +} \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/world.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/world.kt new file mode 100644 index 00000000..c94c6e10 --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/api/interactions/world.kt @@ -0,0 +1,35 @@ +package org.apollo.game.plugin.testing.junit.api.interactions + +import org.apollo.cache.def.NpcDefinition +import org.apollo.game.model.Position +import org.apollo.game.model.World +import org.apollo.game.model.entity.Npc +import org.apollo.game.model.entity.obj.GameObject +import org.apollo.game.model.entity.obj.StaticGameObject + +/** + * Spawn a new static game object into the world with the given id and position. + */ +fun World.spawnObject(id: Int, position: Position): GameObject { + val obj = StaticGameObject(this, id, position, 0, 0) + + spawn(obj) + + return obj +} + + +/** + * Spawns a new NPC with the minimum set of dependencies required to function correctly in the world. + */ +fun World.spawnNpc(id: Int, position: Position): Npc { + val definition = NpcDefinition(id) + val npc = Npc(this, position, definition, arrayOfNulls(4)) + val region = regionRepository.fromPosition(position) + val npcs = npcRepository + + npcs.add(npc) + region.addEntity(npc) + + return npc +} \ No newline at end of file diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/mocking/StubPrototype.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/mocking/StubPrototype.kt new file mode 100644 index 00000000..924b2d5b --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/mocking/StubPrototype.kt @@ -0,0 +1,5 @@ +package org.apollo.game.plugin.testing.junit.mocking + +import kotlin.reflect.KClass + +data class StubPrototype(val type: KClass, val annotations: Collection) diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/GameObjectStubInfo.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/GameObjectStubInfo.kt new file mode 100644 index 00000000..c5306f1f --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/GameObjectStubInfo.kt @@ -0,0 +1,2 @@ +package org.apollo.game.plugin.testing.junit.stubs + diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/NpcStubInfo.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/NpcStubInfo.kt new file mode 100644 index 00000000..c5306f1f --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/NpcStubInfo.kt @@ -0,0 +1,2 @@ +package org.apollo.game.plugin.testing.junit.stubs + diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/PlayerStubInfo.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/PlayerStubInfo.kt new file mode 100644 index 00000000..95f4404a --- /dev/null +++ b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/junit/stubs/PlayerStubInfo.kt @@ -0,0 +1,25 @@ +package org.apollo.game.plugin.testing.junit.stubs + +import org.apollo.game.model.Position +import org.apollo.game.plugin.testing.junit.api.annotations.Pos + +class PlayerStubInfo { + companion object { + fun create(annotations: Collection): PlayerStubInfo { + val info = PlayerStubInfo() + + annotations.forEach { + when (it) { + is Pos -> info.position = Position(it.x, it.y, it.height) + } + } + + return info + } + + } + + var position = Position(3222, 3222) + var name = "test" +} + diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinArgMatcher.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinArgMatcher.kt deleted file mode 100644 index 74814626..00000000 --- a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinArgMatcher.kt +++ /dev/null @@ -1,25 +0,0 @@ -package org.apollo.game.plugin.testing.mockito - -import org.mockito.ArgumentMatcher -import java.lang.AssertionError -import java.util.function.Consumer - -class KotlinArgMatcher(val consumer: Consumer) : ArgumentMatcher() { - private var error: String? = null - - override fun matches(argument: Any?): Boolean { - try { - consumer.accept(argument as T) - return true - } catch (err: AssertionError) { - error = err.message - println(error) - return false - } - } - - override fun toString(): String { - return error ?: "" - } -} - diff --git a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinMockitoExtensions.kt b/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinMockitoExtensions.kt deleted file mode 100644 index 0bfad491..00000000 --- a/game/plugin-testing/src/main/kotlin/org/apollo/game/plugin/testing/mockito/KotlinMockitoExtensions.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.apollo.game.plugin.testing.mockito - -import org.mockito.Mockito -import java.util.function.Consumer - - -object KotlinMockitoExtensions { - inline fun matches(crossinline callback: T.() -> Unit): T { - val consumer = Consumer { it.callback() } - val matcher = KotlinArgMatcher(consumer) - - return Mockito.argThat(matcher) - } -} diff --git a/game/plugin/api/src/player.kt b/game/plugin/api/src/player.kt index 473b13f6..069ffe97 100644 --- a/game/plugin/api/src/player.kt +++ b/game/plugin/api/src/player.kt @@ -29,30 +29,28 @@ val Player.runecraft: SkillProxy get() = SkillProxy(skillSet, Skill.RUNECRAFT) /** * A proxy class to allow */ -class SkillProxy(val skills: SkillSet, val skill: Int) { +class SkillProxy(private val skills: SkillSet, private val skill: Int) { /** * The maximum level of this skill. */ - val maximum = skills.getMaximumLevel(skill) + val maximum: Int + get() = skills.getMaximumLevel(skill) /** * The current level of this skill. */ - val current = skills.getCurrentLevel(skill) - - val experience = ExperienceProxy() + val current: Int + get() = skills.getCurrentLevel(skill) /** - * A proxy class to make [experience] (effectively) write-only. + * The amount of experience in this skill a player has. */ - inner class ExperienceProxy { - - operator fun plusAssign(amount: Int) = skills.addExperience(skill, amount.toDouble()) - - operator fun plusAssign(amount: Double) = skills.addExperience(skill, amount) - - } + var experience: Double + get() = skills.getExperience(skill) + set(value) { + skills.setExperience(skill, value) + } /** * Boosts the current level of this skill by [amount], if possible (i.e. if `current + amount <= maximum + amount`). diff --git a/game/plugin/api/src/util.kt b/game/plugin/api/src/util.kt index 01264cbf..663aa3de 100644 --- a/game/plugin/api/src/util.kt +++ b/game/plugin/api/src/util.kt @@ -1,8 +1,8 @@ package org.apollo.game.plugin.api -import java.util.Random +import java.util.* -val RAND = Random() +public val RAND = Random() fun rand(bounds: Int): Int { return RAND.nextInt(bounds) diff --git a/game/plugin/api/test/NamedLookupTests.kt b/game/plugin/api/test/NamedLookupTests.kt deleted file mode 100644 index 4f361db6..00000000 --- a/game/plugin/api/test/NamedLookupTests.kt +++ /dev/null @@ -1,17 +0,0 @@ -import org.apollo.cache.def.ItemDefinition -import org.apollo.game.plugin.api.Definitions -import org.apollo.game.plugin.testing.KotlinPluginTest -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test - -class NamedLookupTests : KotlinPluginTest() { - @Test - fun itemLookup() { - val testItem = ItemDefinition(0) - testItem.name = "sword" - - ItemDefinition.init(arrayOf(testItem)) - - assertThat(Definitions.item("sword")).isEqualTo(testItem) - } -} \ No newline at end of file diff --git a/game/plugin/bank/test/OpenBankTest.kt b/game/plugin/bank/test/OpenBankTest.kt index d7b4ed1c..58b7b5e4 100644 --- a/game/plugin/bank/test/OpenBankTest.kt +++ b/game/plugin/bank/test/OpenBankTest.kt @@ -1,9 +1,19 @@ -import org.apollo.game.model.Position -import org.apollo.game.plugin.testing.KotlinPluginTest -import org.junit.Test -import org.mockito.Mockito.verify -class OpenBankTest() : KotlinPluginTest() { +import org.apollo.game.model.Position +import org.apollo.game.model.World +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.testing.assertions.verifyAfter +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.apollo.game.plugin.testing.junit.api.interactions.interactWith +import org.apollo.game.plugin.testing.junit.api.interactions.spawnNpc +import org.apollo.game.plugin.testing.junit.api.interactions.spawnObject +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApolloTestingExtension::class) +class OpenBankTest { companion object { const val BANK_BOOTH_ID = 2213 @@ -12,15 +22,23 @@ class OpenBankTest() : KotlinPluginTest() { val BANK_POSITION = Position(3200, 3200, 0) } + @TestMock + lateinit var action: ActionCapture + + @TestMock + lateinit var player: Player + + @TestMock + lateinit var world: World + @Test fun `Interacting with a bank teller should open the players bank`() { val bankTeller = world.spawnNpc(BANK_TELLER_ID, BANK_POSITION) // @todo - these option numbers only match by coincidence, we should be looking up the correct ones player.interactWith(bankTeller, option = 2) - player.waitForActionCompletion() - verify(player).openBank() + verifyAfter(action.complete()) { player.openBank() } } @Test @@ -28,9 +46,8 @@ class OpenBankTest() : KotlinPluginTest() { val bankBooth = world.spawnObject(BANK_BOOTH_ID, BANK_POSITION) player.interactWith(bankBooth, option = 2) - player.waitForActionCompletion() - verify(player).openBank() + verifyAfter(action.complete()) { player.openBank() } } } \ No newline at end of file diff --git a/game/plugin/build.gradle b/game/plugin/build.gradle index e423c472..7bc92461 100644 --- a/game/plugin/build.gradle +++ b/game/plugin/build.gradle @@ -1,6 +1,21 @@ +buildscript { + repositories { + jcenter() + mavenCentral() + + maven { url "https://plugins.gradle.org/m2/" } + } + + dependencies { + classpath 'org.junit.platform:junit-platform-gradle-plugin:1.1.0' + } +} + subprojects { subproj -> if (subproj.buildFile.exists()) { apply plugin: 'apollo-plugin' + apply plugin: 'org.junit.platform.gradle.plugin' + dependencies { implementation group: 'com.google.guava', name: 'guava', version: guavaVersion diff --git a/game/plugin/consumables/test/FoodOrDrinkTests.kt b/game/plugin/consumables/test/FoodOrDrinkTests.kt index 1394e64b..851f3290 100644 --- a/game/plugin/consumables/test/FoodOrDrinkTests.kt +++ b/game/plugin/consumables/test/FoodOrDrinkTests.kt @@ -1,16 +1,24 @@ package org.apollo.plugin.consumables -import org.apollo.game.message.impl.ItemOptionMessage +import io.mockk.verify +import org.apollo.game.model.entity.Player import org.apollo.game.model.entity.Skill -import org.apollo.game.plugin.testing.KotlinPluginTest -import org.apollo.game.plugin.testing.mockito.KotlinMockitoExtensions.matches -import org.assertj.core.api.Assertions.assertThat -import org.junit.Before -import org.junit.Test -import org.mockito.Mockito.never -import org.mockito.Mockito.verify +import org.apollo.game.plugin.testing.assertions.contains +import org.apollo.game.plugin.testing.assertions.after +import org.apollo.game.plugin.testing.assertions.verifyAfter +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.apollo.game.plugin.testing.junit.api.interactions.interactWithItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.junit.jupiter.api.extension.ExtendWith -class FoodOrDrinkTests : KotlinPluginTest() { +@ExtendWith(ApolloTestingExtension::class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class FoodOrDrinkTests { companion object { const val TEST_FOOD_NAME = "test_food" @@ -25,9 +33,14 @@ class FoodOrDrinkTests : KotlinPluginTest() { const val MAX_HP_LEVEL = 10 } - @Before override fun setup() { - super.setup() + @TestMock + lateinit var player: Player + @TestMock + lateinit var action: ActionCapture + + @BeforeEach + fun setup() { val skills = player.skillSet skills.setCurrentLevel(Skill.HITPOINTS, HP_LEVEL) skills.setMaximumLevel(Skill.HITPOINTS, MAX_HP_LEVEL) @@ -36,51 +49,47 @@ class FoodOrDrinkTests : KotlinPluginTest() { drink("test_drink", TEST_DRINK_ID, TEST_DRINK_RESTORATION) } - @Test fun `Consuming food or drink should restore the players hitpoints`() { + @Test + fun `Consuming food or drink should restore the players hitpoints`() { val expectedHpLevel = TEST_FOOD_RESTORATION + HP_LEVEL - player.notify(ItemOptionMessage(1, -1, TEST_FOOD_ID, 1)) - player.waitForActionCompletion() + player.interactWithItem(TEST_FOOD_ID, option = 1, slot = 1) - val currentHpLevel = player.skillSet.getCurrentLevel(Skill.HITPOINTS) - assertThat(currentHpLevel).isEqualTo(expectedHpLevel) + after(action.complete()) { + assertEquals(expectedHpLevel, player.skillSet.getCurrentLevel(Skill.HITPOINTS)) + } } - @Test fun `A message should be sent notifying the player if the item restored hitpoints`() { - player.notify(ItemOptionMessage(1, -1, TEST_FOOD_ID, 1)) - player.waitForActionCompletion() + @Test + fun `A message should be sent notifying the player if the item restored hitpoints`() { + player.interactWithItem(TEST_FOOD_ID, option = 1, slot = 1) - verify(player).sendMessage(matches { - assertThat(this).contains("heals some health") - }) + verifyAfter(action.complete()) { player.sendMessage("It heals some health.") } } - @Test fun `A message should not be sent to the player if the item did not restore hitpoints`() { + @Test + fun `A message should not be sent to the player if the item did not restore hitpoints`() { player.skillSet.setCurrentLevel(Skill.HITPOINTS, MAX_HP_LEVEL) - player.notify(ItemOptionMessage(1, -1, TEST_FOOD_ID, 1)) - player.waitForActionCompletion() + player.interactWithItem(TEST_FOOD_ID, option = 1, slot = 1) - verify(player, never()).sendMessage(matches { - assertThat(this).contains("heals some health") - }) + after(action.complete()) { + verify(exactly = 0) { player.sendMessage(contains("it heals some")) } + } } - @Test fun `A message should be sent saying the player has drank an item when consuming a drink`() { - player.notify(ItemOptionMessage(1, -1, TEST_DRINK_ID, 1)) - player.waitForActionCompletion() + @Test + fun `A message should be sent saying the player has drank an item when consuming a drink`() { + player.interactWithItem(TEST_DRINK_ID, option = 1, slot = 1) + + verifyAfter(action.complete()) { player.sendMessage("You drink the ${TEST_DRINK_NAME}.") } - verify(player).sendMessage(matches { - assertThat(this).contains("You drink the ${TEST_DRINK_NAME}") - }) } - @Test fun `A message should be sent saying the player has eaten an item when consuming food`() { - player.notify(ItemOptionMessage(1, -1, TEST_FOOD_ID, 1)) - player.waitForActionCompletion() + @Test + fun `A message should be sent saying the player has eaten an item when consuming food`() { + player.interactWithItem(TEST_FOOD_ID, option = 1, slot = 1) - verify(player).sendMessage(matches { - assertThat(this).contains("You eat the ${TEST_FOOD_NAME}") - }) + verifyAfter(action.complete()) { player.sendMessage("You eat the ${TEST_FOOD_NAME}.") } } } \ No newline at end of file diff --git a/game/plugin/dummy/test/TrainingDummyTest.kt b/game/plugin/dummy/test/TrainingDummyTest.kt index 2e99e313..4a4284b5 100644 --- a/game/plugin/dummy/test/TrainingDummyTest.kt +++ b/game/plugin/dummy/test/TrainingDummyTest.kt @@ -1,43 +1,64 @@ -import org.apollo.game.model.Position -import org.apollo.game.model.entity.Skill -import org.apollo.game.plugin.testing.* -import org.assertj.core.api.Assertions.assertThat -import org.junit.Test -import org.mockito.Mockito.contains -import org.mockito.Mockito.verify -class TrainingDummyTest : KotlinPluginTest() { +import io.mockk.verify +import org.apollo.game.model.Position +import org.apollo.game.model.World +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.Skill +import org.apollo.game.plugin.testing.assertions.after +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.apollo.game.plugin.testing.junit.api.interactions.interactWith +import org.apollo.game.plugin.testing.junit.api.interactions.spawnObject +import org.apollo.game.plugin.testing.assertions.contains +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApolloTestingExtension::class) +class TrainingDummyTest { companion object { const val DUMMY_ID = 823 val DUMMY_POSITION = Position(3200, 3230, 0) } - @Test fun `Hitting the training dummy should give the player attack experience`() { + @TestMock + lateinit var action: ActionCapture + + @TestMock + lateinit var player: Player + + @TestMock + lateinit var world: World + + @Test + fun `Hitting the training dummy should give the player attack experience`() { val dummy = world.spawnObject(DUMMY_ID, DUMMY_POSITION) val skills = player.skillSet val beforeExp = skills.getExperience(Skill.ATTACK) player.interactWith(dummy, option = 2) - player.waitForActionCompletion() - val afterExp = skills.getExperience(Skill.ATTACK) - assertThat(afterExp).isGreaterThan(beforeExp) + after(action.complete()) { + assertTrue(skills.getExperience(Skill.ATTACK) > beforeExp) + } } - @Test fun `The player should stop getting attack experience from the training dummy at level 8`() { + @Test + fun `The player should stop getting attack experience from the training dummy at level 8`() { val dummy = world.spawnObject(DUMMY_ID, DUMMY_POSITION) val skills = player.skillSet skills.setMaximumLevel(Skill.ATTACK, 8) val beforeExp = skills.getExperience(Skill.ATTACK) player.interactWith(dummy, option = 2) - player.waitForActionCompletion() - val afterExp = skills.getExperience(Skill.ATTACK) - - verify(player).sendMessage(contains("nothing more you can learn")) - assertThat(afterExp).isEqualTo(beforeExp) + after(action.complete()) { + verify { player.sendMessage(contains("nothing more you can learn")) } + assertEquals(beforeExp, skills.getExperience(Skill.ATTACK)) + } } } diff --git a/game/plugin/logout/test/LogoutTests.kt b/game/plugin/logout/test/LogoutTests.kt index e974c135..c536976f 100644 --- a/game/plugin/logout/test/LogoutTests.kt +++ b/game/plugin/logout/test/LogoutTests.kt @@ -1,19 +1,27 @@ -import org.apollo.game.message.impl.ButtonMessage -import org.apollo.game.plugin.testing.KotlinPluginTest -import org.junit.Test -import org.mockito.Mockito.times -import org.mockito.Mockito.verify -class LogoutTests : KotlinPluginTest() { +import io.mockk.verify +import org.apollo.game.message.impl.ButtonMessage +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApolloTestingExtension::class) +class LogoutTests { companion object { const val LOGOUT_BUTTON_ID = 2458 } - @Test fun `The player should be logged out when they click the logout button`() { - player.notify(ButtonMessage(LOGOUT_BUTTON_ID)) + @TestMock + lateinit var player: Player - verify(player, times(1)).logout() + @Test + fun `The player should be logged out when they click the logout button`() { + player.send(ButtonMessage(LOGOUT_BUTTON_ID)) + + verify { player.logout() } } } \ No newline at end of file diff --git a/game/plugin/skills/fishing/src/fishing.kt b/game/plugin/skills/fishing/src/fishing.kt index b5994935..807ac994 100644 --- a/game/plugin/skills/fishing/src/fishing.kt +++ b/game/plugin/skills/fishing/src/fishing.kt @@ -9,12 +9,12 @@ import org.apollo.game.plugin.skills.fishing.FishingTool.* /** * A fish that can be gathered using the fishing skill. */ -enum class Fish(val id: Int, val level: Int, val experience: Double) { - SHRIMP(id = 317, level = 1, experience = 10.0), +enum class Fish(val id: Int, val level: Int, val experience: Double, catchSuffix: String? = null) { + SHRIMPS(id = 317, level = 1, experience = 10.0, catchSuffix = "some shrimp."), SARDINE(id = 327, level = 5, experience = 20.0), MACKEREL(id = 353, level = 16, experience = 20.0), HERRING(id = 345, level = 10, experience = 30.0), - ANCHOVY(id = 321, level = 15, experience = 40.0), + ANCHOVIES(id = 321, level = 15, experience = 40.0, catchSuffix = "some anchovies."), TROUT(id = 335, level = 20, experience = 50.0), COD(id = 341, level = 23, experience = 45.0), PIKE(id = 349, level = 25, experience = 60.0), @@ -23,13 +23,14 @@ enum class Fish(val id: Int, val level: Int, val experience: Double) { LOBSTER(id = 377, level = 40, experience = 90.0), BASS(id = 363, level = 46, experience = 100.0), SWORDFISH(id = 371, level = 50, experience = 100.0), - SHARK(id = 383, level = 76, experience = 110.0); + SHARK(id = 383, level = 76, experience = 110.0, catchSuffix = "a shark!"); /** * The name of this fish, formatted so it can be inserted into a message. */ - val formattedName = Definitions.item(id)!!.name.toLowerCase() - // TODO this leads to incorrect messages, e.g. 'You catch a raw shrimps'. + val catchMessage = "You catch ${catchSuffix ?: "a ${catchName()}."}" + + private fun catchName() = Definitions.item(id)!!.name.toLowerCase().removePrefix("raw ") } @@ -69,16 +70,7 @@ enum class FishingSpot(val npc: Int, private val first: Option, private val seco ROD(309, Option.of(FLY_FISHING_ROD, TROUT, SALMON), Option.of(FISHING_ROD, PIKE)), CAGE_HARPOON(312, Option.of(LOBSTER_CAGE, LOBSTER), Option.of(HARPOON, TUNA, SWORDFISH)), NET_HARPOON(313, Option.of(BIG_NET, MACKEREL, COD), Option.of(HARPOON, BASS, SHARK)), - NET_ROD(316, Option.of(SMALL_NET, SHRIMP, ANCHOVY), Option.of(FISHING_ROD, SARDINE, HERRING)); - - companion object { - private val FISHING_SPOTS = FishingSpot.values().associateBy({ it.npc }, { it }) - - /** - * Returns the [FishingSpot] with the specified [id], or `null` if the spot does not exist. - */ - fun lookup(id: Int): FishingSpot? = FISHING_SPOTS[id] - } + NET_ROD(316, Option.of(SMALL_NET, SHRIMPS, ANCHOVIES), Option.of(FISHING_ROD, SARDINE, HERRING)); /** * Returns the [FishingSpot.Option] associated with the specified action id. @@ -159,4 +151,13 @@ enum class FishingSpot(val npc: Int, private val first: Option, private val seco } + companion object { + private val FISHING_SPOTS = FishingSpot.values().associateBy(FishingSpot::npc) + + /** + * Returns the [FishingSpot] with the specified [id], or `null` if the spot does not exist. + */ + fun lookup(id: Int): FishingSpot? = FISHING_SPOTS[id] + } + } diff --git a/game/plugin/skills/fishing/src/fishing.plugin.kts b/game/plugin/skills/fishing/src/fishing.plugin.kts index a2138bd9..25054564 100644 --- a/game/plugin/skills/fishing/src/fishing.plugin.kts +++ b/game/plugin/skills/fishing/src/fishing.plugin.kts @@ -57,7 +57,7 @@ class FishingAction(player: Player, position: Position, val option: FishingSpot. } mob.inventory.add(fish.id) - mob.sendMessage("You catch a ${fish.formattedName}.") + mob.sendMessage(fish.catchMessage) mob.fishing.experience += fish.experience if (mob.inventory.freeSlots() == 0) { diff --git a/game/plugin/skills/mining/build.gradle b/game/plugin/skills/mining/build.gradle index 71bc9020..3ea6934e 100644 --- a/game/plugin/skills/mining/build.gradle +++ b/game/plugin/skills/mining/build.gradle @@ -10,5 +10,5 @@ plugin { "tlf30" ] - dependencies = ["api"] + dependencies = ["api", "util:lookup"] } \ No newline at end of file diff --git a/game/plugin/skills/mining/src/gem.kt b/game/plugin/skills/mining/src/gem.kt index 6f8fa2e7..2252ea4e 100644 --- a/game/plugin/skills/mining/src/gem.kt +++ b/game/plugin/skills/mining/src/gem.kt @@ -8,6 +8,6 @@ enum class Gem(val id: Int) { // TODO add gem drop chances companion object { private val GEMS = Gem.values().associateBy({ it.id }, { it }) - fun lookup(id: Int): Gem? = GEMS[id] + operator fun get(id: Int): Gem? = GEMS[id] } } \ No newline at end of file diff --git a/game/plugin/skills/mining/src/mining.kt b/game/plugin/skills/mining/src/mining.kt new file mode 100644 index 00000000..0dc0f778 --- /dev/null +++ b/game/plugin/skills/mining/src/mining.kt @@ -0,0 +1,146 @@ +import org.apollo.game.action.ActionBlock +import org.apollo.game.action.AsyncDistancedAction +import org.apollo.game.message.impl.ObjectActionMessage +import org.apollo.game.model.Position +import org.apollo.game.model.World +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.obj.GameObject +import org.apollo.game.plugin.api.* +import org.apollo.game.plugin.skills.mining.Ore +import org.apollo.game.plugin.skills.mining.Pickaxe +import org.apollo.net.message.Message +import java.util.* + +class MiningAction( + player: Player, + private val tool: Pickaxe, + private val target: MiningTarget +) : AsyncDistancedAction(PULSES, true, player, target.position, ORE_SIZE) { + + companion object { + private const val PULSES = 0 + private const val ORE_SIZE = 1 + + /** + * Starts a [MiningAction] for the specified [Player], terminating the [Message] that triggered it. + */ + fun start(message: ObjectActionMessage, player: Player, ore: Ore) { + val pickaxe = Pickaxe.bestFor(player) + + if (pickaxe == null) { + player.sendMessage("You do not have a pickaxe for which you have the level to use.") + } else { + val target = MiningTarget(message.id, message.position, ore) + val action = MiningAction(player, pickaxe, target) + + player.startAction(action) + } + + message.terminate() + } + } + + override fun action(): ActionBlock = { + mob.turnTo(position) + + if (!target.skillRequirementsMet(mob)) { + mob.sendMessage("You do not have the required level to mine this rock.") + stop() + } + + mob.sendMessage("You swing your pick at the rock.") + mob.playAnimation(tool.animation) + + wait(tool.pulses) + + if (!target.isValid(mob.world)) { + stop() + } + + val successChance = rand(100) + + if (target.isSuccessful(mob, successChance)) { + if (mob.inventory.freeSlots() == 0) { + mob.inventory.forceCapacityExceeded() + stop() + } + + if (target.reward(mob)) { + mob.sendMessage("You manage to mine some ${target.oreName()}") + target.deplete(mob.world) + + stop() + } + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MiningAction + return mob == other.mob && target == other.target + } + + override fun hashCode(): Int = Objects.hash(mob, target) + +} + +data class MiningTarget(val objectId: Int, val position: Position, val ore: Ore) { + + /** + * Get the [GameObject] represented by this target. + * + * @todo: api: shouldn't be as verbose + */ + private fun getObject(world: World): GameObject? { + val region = world.regionRepository.fromPosition(position) + return region.findObject(position, objectId).orElse(null) + } + + /** + * Deplete this mining resource from the [World], and schedule it to be respawned + * in a number of ticks specified by the [Ore]. + */ + fun deplete(world: World) { + world.expireObject(getObject(world)!!, ore.objects[objectId]!!, ore.respawn) + } + + /** + * Check if the [Player] was successful in mining this ore with a random success [chance] value between 0 and 100. + */ + fun isSuccessful(mob: Player, chance: Int): Boolean { + val percent = (ore.chance * mob.mining.current + ore.chanceOffset) * 100 + return chance < percent + } + + /** + * Check if this target is still valid in the [World] (i.e. has not been [deplete]d). + */ + fun isValid(world: World) = getObject(world) != null + + /** + * Get the normalized name of the [Ore] represented by this target. + */ + fun oreName() = Definitions.item(ore.id)!!.name.toLowerCase() + + /** + * Reward a [player] with experience and ore if they have the inventory capacity to take a new ore. + */ + fun reward(player: Player): Boolean { + val hasInventorySpace = player.inventory.add(ore.id) + + if (hasInventorySpace) { + player.mining.experience += ore.exp + } + + return hasInventorySpace + } + + /** + * Check if the [mob] has met the skill requirements to mine te [Ore] represented by + * this [MiningTarget]. + */ + fun skillRequirementsMet(mob: Player) = mob.mining.current < ore.level +} + diff --git a/game/plugin/skills/mining/src/mining.plugin.kts b/game/plugin/skills/mining/src/mining.plugin.kts index 83ad0621..8b3f61d1 100644 --- a/game/plugin/skills/mining/src/mining.plugin.kts +++ b/game/plugin/skills/mining/src/mining.plugin.kts @@ -1,19 +1,5 @@ -import org.apollo.game.action.ActionBlock -import org.apollo.game.action.AsyncDistancedAction import org.apollo.game.message.impl.ObjectActionMessage -import org.apollo.game.model.Position -import org.apollo.game.model.World -import org.apollo.game.model.entity.Player -import org.apollo.game.model.entity.obj.GameObject -import org.apollo.game.plugin.api.Definitions -import org.apollo.game.plugin.api.expireObject -import org.apollo.game.plugin.api.findObject -import org.apollo.game.plugin.api.mining -import org.apollo.game.plugin.api.rand import org.apollo.game.plugin.skills.mining.Ore -import org.apollo.game.plugin.skills.mining.Pickaxe -import org.apollo.net.message.Message -import java.util.Objects on { ObjectActionMessage::class } .where { option == Actions.MINING } @@ -35,99 +21,6 @@ on { ObjectActionMessage::class } } } -class MiningAction( - player: Player, - private val tool: Pickaxe, - private val target: MiningTarget -) : AsyncDistancedAction(PULSES, true, player, target.position, ORE_SIZE) { - - companion object { - private const val PULSES = 0 - private const val ORE_SIZE = 1 - - /** - * Starts a [MiningAction] for the specified [Player], terminating the [Message] that triggered it. - */ - fun start(message: ObjectActionMessage, player: Player, ore: Ore) { - val pickaxe = Pickaxe.bestFor(player) - - if (pickaxe == null) { - player.sendMessage("You do not have a pickaxe for which you have the level to use.") - } else { - val target = MiningTarget(message.id, message.position, ore) - val action = MiningAction(player, pickaxe, target) - - player.startAction(action) - } - - message.terminate() - } - } - - override fun action(): ActionBlock = { - mob.turnTo(position) - - val level = mob.mining.current - if (level < target.ore.level) { - mob.sendMessage("You do not have the required level to mine this rock.") - stop() - } - - mob.sendMessage("You swing your pick at the rock.") - mob.playAnimation(tool.animation) - - wait(tool.pulses) - - val obj = target.getObject(mob.world) - if (obj == null) { - stop() - } - - if (target.isSuccessful(mob)) { - if (mob.inventory.freeSlots() == 0) { - mob.inventory.forceCapacityExceeded() - stop() - } - - if (mob.inventory.add(target.ore.id)) { - val oreName = Definitions.item(target.ore.id)!!.name.toLowerCase() - mob.sendMessage("You manage to mine some $oreName") - - mob.mining.experience += target.ore.exp - mob.world.expireObject(obj!!, target.ore.objects[target.objectId]!!, target.ore.respawn) - stop() - } - } - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as MiningAction - return mob == other.mob && target == other.target - } - - override fun hashCode(): Int = Objects.hash(mob, target) - -} - -data class MiningTarget(val objectId: Int, val position: Position, val ore: Ore) { - - fun getObject(world: World): GameObject? { - val region = world.regionRepository.fromPosition(position) - return region.findObject(position, objectId).orElse(null) - } - - fun isSuccessful(mob: Player): Boolean { - val offset = if (ore.chanceOffset) 1 else 0 - val percent = (ore.chance * mob.mining.current + offset) * 100 - - return rand(100) < percent - } - -} - private object Actions { const val MINING = 1 const val PROSPECTING = 2 diff --git a/game/plugin/skills/mining/src/ore.kt b/game/plugin/skills/mining/src/ore.kt index e272b182..7e76cf81 100644 --- a/game/plugin/skills/mining/src/ore.kt +++ b/game/plugin/skills/mining/src/ore.kt @@ -17,12 +17,12 @@ enum class Ore( val exp: Double, val respawn: Int, val chance: Double, - val chanceOffset: Boolean = false + val chanceOffset: Double = 0.0 ) { - CLAY(CLAY_OBJECTS, id = 434, level = 1, exp = 5.0, respawn = 1, chance = 0.0085, chanceOffset = true), - COPPER(COPPER_OBJECTS, id = 436, level = 1, exp = 17.5, respawn = 4, chance = 0.0085, chanceOffset = true), - TIN(TIN_OBJECTS, id = 438, level = 1, exp = 17.5, respawn = 4, chance = 0.0085, chanceOffset = true), - IRON(IRON_OBJECTS, id = 440, level = 15, exp = 35.0, respawn = 9, chance = 0.0085, chanceOffset = true), + CLAY(CLAY_OBJECTS, id = 434, level = 1, exp = 5.0, respawn = 1, chance = 0.0085, chanceOffset = 0.45), + COPPER(COPPER_OBJECTS, id = 436, level = 1, exp = 17.5, respawn = 4, chance = 0.0085, chanceOffset = 0.45), + TIN(TIN_OBJECTS, id = 438, level = 1, exp = 17.5, respawn = 4, chance = 0.0085, chanceOffset = 0.45), + IRON(IRON_OBJECTS, id = 440, level = 15, exp = 35.0, respawn = 9, chance = 0.0085, chanceOffset = 0.45), COAL(COAL_OBJECTS, id = 453, level = 30, exp = 50.0, respawn = 50, chance = 0.004), GOLD(GOLD_OBJECTS, id = 444, level = 40, exp = 65.0, respawn = 100, chance = 0.003), SILVER(SILVER_OBJECTS, id = 442, level = 20, exp = 40.0, respawn = 100, chance = 0.0085), diff --git a/game/plugin/skills/mining/src/pickaxe.kt b/game/plugin/skills/mining/src/pickaxe.kt index a669f492..45071f0c 100644 --- a/game/plugin/skills/mining/src/pickaxe.kt +++ b/game/plugin/skills/mining/src/pickaxe.kt @@ -7,7 +7,7 @@ import org.apollo.game.plugin.api.mining enum class Pickaxe(val id: Int, val level: Int, animation: Int, val pulses: Int) { BRONZE(id = 1265, level = 1, animation = 625, pulses = 8), ITRON(id = 1267, level = 1, animation = 626, pulses = 7), - STEEL(id = 1269, level = 1, animation = 627, pulses = 6), + STEEL(id = 1269, level = 6, animation = 627, pulses = 6), MITHRIL(id = 1273, level = 21, animation = 629, pulses = 5), ADAMANT(id = 1271, level = 31, animation = 628, pulses = 4), RUNE(id = 1275, level = 41, animation = 624, pulses = 3); diff --git a/game/plugin/skills/mining/test/MiningActionTests.kt b/game/plugin/skills/mining/test/MiningActionTests.kt new file mode 100644 index 00000000..a378f0ca --- /dev/null +++ b/game/plugin/skills/mining/test/MiningActionTests.kt @@ -0,0 +1,80 @@ + +import io.mockk.every +import io.mockk.spyk +import io.mockk.staticMockk +import io.mockk.verify +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.model.World +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.Skill +import org.apollo.game.plugin.api.expireObject +import org.apollo.game.plugin.skills.mining.Ore +import org.apollo.game.plugin.skills.mining.Pickaxe +import org.apollo.game.plugin.skills.mining.TIN_OBJECTS +import org.apollo.game.plugin.testing.assertions.after +import org.apollo.game.plugin.testing.assertions.verifyAfter +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.apollo.game.plugin.testing.junit.api.interactions.spawnObject +import org.apollo.game.plugin.testing.assertions.contains +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith + +@ExtendWith(ApolloTestingExtension::class) +class MiningActionTests { + private val TIN_OBJ_IDS = TIN_OBJECTS.entries.first() + + @ItemDefinitions + fun ores() = Ore.values() + .map { ItemDefinition(it.id).also { it.name = "" } } + + @ItemDefinitions + fun pickaxes() = listOf(ItemDefinition(Pickaxe.BRONZE.id)) + + @TestMock + lateinit var world: World + + @TestMock + lateinit var player: Player + + @TestMock + lateinit var action: ActionCapture + + @Test + fun `Attempting to mine a rock we don't have the skill to should send the player a message`() { + val obj = world.spawnObject(1, player.position) + val target = spyk(MiningTarget(obj.id, obj.position, Ore.TIN)) + + every { target.skillRequirementsMet(player) } returns false + player.startAction(MiningAction(player, Pickaxe.BRONZE, target)) + verifyAfter(action.complete()) { player.sendMessage(contains("do not have the required level")) } + } + + @Test + fun `Mining a rock we have the skill to mine should eventually reward ore and experience`() { + val (tinId, expiredTinId) = TIN_OBJ_IDS + val obj = world.spawnObject(tinId, player.position) + val target = spyk(MiningTarget(obj.id, obj.position, Ore.TIN)) + staticMockk("org.apollo.game.plugin.api.WorldKt").mock() + + every { target.skillRequirementsMet(player) } returns true + every { target.isSuccessful(player, any()) } returns true + every { world.expireObject(obj, any(), any()) } answers {} + + player.skillSet.setCurrentLevel(Skill.MINING, Ore.TIN.level) + player.startAction(MiningAction(player, Pickaxe.BRONZE, target)) + + verifyAfter(action.ticks(1)) { player.sendMessage(contains("You swing your pick")) } + after(action.complete()) { + verify { player.sendMessage("You manage to mine some ") } + verify { world.expireObject(obj, expiredTinId, Ore.TIN.respawn) } + + assertTrue(player.inventory.contains(Ore.TIN.id)) + assertEquals(player.skillSet.getExperience(Skill.MINING), Ore.TIN.exp) + } + } +} \ No newline at end of file diff --git a/game/plugin/skills/mining/test/PickaxeTests.kt b/game/plugin/skills/mining/test/PickaxeTests.kt new file mode 100644 index 00000000..15198c0d --- /dev/null +++ b/game/plugin/skills/mining/test/PickaxeTests.kt @@ -0,0 +1,75 @@ +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.Skill +import org.apollo.game.plugin.skills.mining.Pickaxe +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +@ExtendWith(ApolloTestingExtension::class) +class PickaxeTests { + + @ItemDefinitions + fun pickaxes() = Pickaxe.values().map { + ItemDefinition(it.id).apply { isStackable = false } + } + + @TestMock + lateinit var player: Player + + @ParameterizedTest + @EnumSource(Pickaxe::class) + fun `No pickaxe is chosen if none are available`(pickaxe: Pickaxe) { + player.skillSet.setCurrentLevel(Skill.MINING, pickaxe.level) + + assertEquals(null, Pickaxe.bestFor(player)) + } + + + @ParameterizedTest + @EnumSource(Pickaxe::class) + fun `The highest level pickaxe is chosen when available`(pickaxe: Pickaxe) { + player.skillSet.setCurrentLevel(Skill.MINING, pickaxe.level) + player.inventory.add(pickaxe.id) + + assertEquals(pickaxe, Pickaxe.bestFor(player)) + } + + @ParameterizedTest + @EnumSource(Pickaxe::class) + fun `Only pickaxes the player has are chosen`(pickaxe: Pickaxe) { + player.skillSet.setCurrentLevel(Skill.MINING, pickaxe.level) + player.inventory.add(Pickaxe.BRONZE.id) + + assertEquals(Pickaxe.BRONZE, Pickaxe.bestFor(player)) + } + + + @ParameterizedTest + @EnumSource(value = Pickaxe::class) + fun `Pickaxes can be chosen from equipment as well as inventory`(pickaxe: Pickaxe) { + player.skillSet.setCurrentLevel(Skill.MINING, pickaxe.level) + player.inventory.add(pickaxe.id) + + assertEquals(pickaxe, Pickaxe.bestFor(player)) + } + + + @ParameterizedTest + @EnumSource(value = Pickaxe::class) + fun `Pickaxes with a level requirement higher than the player's are ignored`(pickaxe: Pickaxe) { + player.skillSet.setCurrentLevel(Skill.MINING, pickaxe.level) + player.inventory.add(pickaxe.id) + + Pickaxe.values() + .filter { it.level > pickaxe.level } + .forEach { player.inventory.add(it.id) } + + assertEquals(pickaxe, Pickaxe.bestFor(player)) + } + +} \ No newline at end of file diff --git a/game/plugin/skills/mining/test/ProspectingTests.kt b/game/plugin/skills/mining/test/ProspectingTests.kt new file mode 100644 index 00000000..0274951d --- /dev/null +++ b/game/plugin/skills/mining/test/ProspectingTests.kt @@ -0,0 +1,46 @@ + +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.skills.mining.Ore +import org.apollo.game.plugin.testing.assertions.verifyAfter +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.apollo.game.plugin.testing.junit.api.interactions.interactWithObject +import org.apollo.game.plugin.testing.assertions.contains +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource + +@ExtendWith(ApolloTestingExtension::class) +class ProspectingTests { + + @ItemDefinitions + fun ores() = Ore.values().map { + ItemDefinition(it.id).also { it.name = "" } + } + + @TestMock + lateinit var player: Player + + @TestMock + lateinit var action: ActionCapture + + @ParameterizedTest + @ArgumentsSource(MiningTestDataProvider::class) + fun `Prospecting a rock should reveal the type of ore it contains`(data: MiningTestData) { + player.interactWithObject(data.rockId, 2) + + verifyAfter(action.ticks(1)) { player.sendMessage(contains("examine the rock")) } + verifyAfter(action.complete()) { player.sendMessage(contains("This rock contains ")) } + } + + @ParameterizedTest + @ArgumentsSource(MiningTestDataProvider::class) + fun `Prospecting an expired rock should reveal it contains no ore`(data: MiningTestData) { + player.interactWithObject(data.expiredRockId, 2) + + verifyAfter(action.complete()) { player.sendMessage(contains("no ore available in this rock")) } + } +} \ No newline at end of file diff --git a/game/plugin/skills/mining/test/TestData.kt b/game/plugin/skills/mining/test/TestData.kt new file mode 100644 index 00000000..e6583035 --- /dev/null +++ b/game/plugin/skills/mining/test/TestData.kt @@ -0,0 +1,17 @@ +import org.apollo.game.plugin.skills.mining.Ore +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import java.util.stream.Stream + +data class MiningTestData(val rockId: Int, val expiredRockId: Int, val ore: Ore) + +fun miningTestData(): Collection = Ore.values() + .flatMap { ore -> ore.objects.map { MiningTestData(it.key, it.value, ore) } } + .toList() + +class MiningTestDataProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return miningTestData().map { Arguments { arrayOf(it) } }.stream() + } +} \ No newline at end of file diff --git a/game/plugin/skills/prayer/src/BoneBuryAction.kt b/game/plugin/skills/prayer/src/BoneBuryAction.kt index f3c46357..c5fe0089 100644 --- a/game/plugin/skills/prayer/src/BoneBuryAction.kt +++ b/game/plugin/skills/prayer/src/BoneBuryAction.kt @@ -25,7 +25,7 @@ class BuryBoneAction( } companion object { - private val BURY_BONE_ANIMATION = Animation(827) + public val BURY_BONE_ANIMATION = Animation(827) internal const val BURY_OPTION = 1 } diff --git a/game/plugin/skills/prayer/src/prayer.plugin.kts b/game/plugin/skills/prayer/src/prayer.plugin.kts index c2857a8f..2338d833 100644 --- a/game/plugin/skills/prayer/src/prayer.plugin.kts +++ b/game/plugin/skills/prayer/src/prayer.plugin.kts @@ -34,4 +34,4 @@ on { ItemOptionMessage::class } player.startAction(BuryBoneAction(player, slot, bone)) terminate() - } \ No newline at end of file + } diff --git a/game/plugin/skills/prayer/test/BuryBoneTests.kt b/game/plugin/skills/prayer/test/BuryBoneTests.kt new file mode 100644 index 00000000..d5aaae14 --- /dev/null +++ b/game/plugin/skills/prayer/test/BuryBoneTests.kt @@ -0,0 +1,72 @@ + +import BuryBoneAction.Companion.BURY_BONE_ANIMATION +import io.mockk.verify +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.api.prayer +import org.apollo.game.plugin.testing.assertions.after +import org.apollo.game.plugin.testing.assertions.startsWith +import org.apollo.game.plugin.testing.assertions.verifyAfter +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.apollo.game.plugin.testing.junit.api.interactions.interactWithItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +@ExtendWith(ApolloTestingExtension::class) +class BuryBoneTests { + + @TestMock + lateinit var player: Player + + @TestMock + lateinit var action: ActionCapture + + @ItemDefinitions + fun bones(): Collection { + return Bone.values().map { ItemDefinition(it.id) } + } + + @ParameterizedTest + @EnumSource(value = Bone::class) + fun `Burying a bone should send a message`(bone: Bone) { + player.inventory.add(bone.id) + player.interactWithItem(bone.id, option = 1) + + verifyAfter(action.ticks(1), "message is sent") { + player.sendMessage(startsWith("You dig a hole")) + } + } + + @ParameterizedTest + @EnumSource(value = Bone::class) + fun `Burying a bone should play an animation`(bone: Bone) { + player.inventory.add(bone.id) + player.interactWithItem(bone.id, option = 1) + + verifyAfter(action.ticks(1), "animation is played") { + player.playAnimation(eq(BURY_BONE_ANIMATION)) + } + } + + @ParameterizedTest + @EnumSource(value = Bone::class) + fun `Burying a bone should give the player experience`(bone: Bone) { + player.inventory.add(bone.id) + player.interactWithItem(bone.id, option = 1) + + action.ticks(1) + + after(action.complete(), "experience is granted after bone burial") { + verify { player.sendMessage(startsWith("You bury the bones")) } + + assertEquals(bone.xp, player.prayer.experience) + assertEquals(player.inventory.getAmount(bone.id), 0) + } + } + +} \ No newline at end of file diff --git a/game/plugin/skills/woodcutting/src/woodcutting.plugin.kts b/game/plugin/skills/woodcutting/src/woodcutting.plugin.kts index 9d3de2ae..f14e5b73 100644 --- a/game/plugin/skills/woodcutting/src/woodcutting.plugin.kts +++ b/game/plugin/skills/woodcutting/src/woodcutting.plugin.kts @@ -90,10 +90,7 @@ class WoodcuttingAction( wait(tool.pulses) // Check that the object exists in the world - val obj = target.getObject(mob.world) - if (obj == null) { - stop() - } + val obj = target.getObject(mob.world) ?: stop() if (mob.inventory.add(target.tree.id)) { val logName = Definitions.item(target.tree.id)!!.name.toLowerCase() @@ -105,7 +102,7 @@ class WoodcuttingAction( // respawn time: http://runescape.wikia.com/wiki/Trees val respawn = TimeUnit.SECONDS.toMillis(MINIMUM_RESPAWN_TIME + rand(150)) / GameConstants.PULSE_DELAY - mob.world.expireObject(obj!!, target.tree.stump, respawn.toInt()) + mob.world.expireObject(obj, target.tree.stump, respawn.toInt()) stop() } } diff --git a/game/plugin/skills/woodcutting/test/AxeTests.kt b/game/plugin/skills/woodcutting/test/AxeTests.kt new file mode 100644 index 00000000..2b2d3a2b --- /dev/null +++ b/game/plugin/skills/woodcutting/test/AxeTests.kt @@ -0,0 +1,72 @@ +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.Skill +import org.apollo.game.plugin.skills.woodcutting.Axe +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.EnumSource + +@ExtendWith(ApolloTestingExtension::class) +class AxeTests { + + @ItemDefinitions + fun axes() = Axe.values().map { + ItemDefinition(it.id).apply { isStackable = false } + } + + @TestMock + lateinit var player: Player + + @ParameterizedTest + @EnumSource(Axe::class) + fun `No axe is chosen if none are available`(axe: Axe) { + player.skillSet.setCurrentLevel(Skill.WOODCUTTING, axe.level) + + assertEquals(null, Axe.bestFor(player)) + } + + @ParameterizedTest + @EnumSource(Axe::class) + fun `The highest level axe is chosen when available`(axe: Axe) { + player.skillSet.setCurrentLevel(Skill.WOODCUTTING, axe.level) + player.inventory.add(axe.id) + + assertEquals(axe, Axe.bestFor(player)) + } + + @ParameterizedTest + @EnumSource(Axe::class) + fun `Only axes the player has are chosen`(axe: Axe) { + player.skillSet.setCurrentLevel(Skill.WOODCUTTING, axe.level) + player.inventory.add(Axe.BRONZE.id) + + assertEquals(Axe.BRONZE, Axe.bestFor(player)) + } + + @ParameterizedTest + @EnumSource(Axe::class) + fun `Axes can be chosen from equipment as well as inventory`(axe: Axe) { + player.skillSet.setCurrentLevel(Skill.WOODCUTTING, axe.level) + player.inventory.add(axe.id) + + assertEquals(axe, Axe.bestFor(player)) + } + + @ParameterizedTest + @EnumSource(Axe::class) + fun `Axes with a level requirement higher than the player's are ignored`(axe: Axe) { + player.skillSet.setCurrentLevel(Skill.WOODCUTTING, axe.level) + player.inventory.add(axe.id) + + Axe.values() + .filter { it.level > axe.level } + .forEach { player.inventory.add(it.id) } + + assertEquals(axe, Axe.bestFor(player)) + } + +} \ No newline at end of file diff --git a/game/plugin/skills/woodcutting/test/TestData.kt b/game/plugin/skills/woodcutting/test/TestData.kt new file mode 100644 index 00000000..073b81ef --- /dev/null +++ b/game/plugin/skills/woodcutting/test/TestData.kt @@ -0,0 +1,17 @@ +import org.apollo.game.plugin.skills.woodcutting.Tree +import org.junit.jupiter.api.extension.ExtensionContext +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.ArgumentsProvider +import java.util.stream.Stream + +data class WoodcuttingTestData(val treeId: Int, val stumpId: Int, val tree: Tree) + +fun woodcuttingTestData(): Collection = Tree.values() + .flatMap { tree -> tree.objects.map { WoodcuttingTestData(it, tree.stump, tree) } } + .toList() + +class WoodcuttingTestDataProvider : ArgumentsProvider { + override fun provideArguments(context: ExtensionContext?): Stream { + return woodcuttingTestData().map { Arguments { arrayOf(it) } }.stream() + } +} \ No newline at end of file diff --git a/game/plugin/skills/woodcutting/test/WoodcuttingTests.kt b/game/plugin/skills/woodcutting/test/WoodcuttingTests.kt new file mode 100644 index 00000000..8c8f1cd4 --- /dev/null +++ b/game/plugin/skills/woodcutting/test/WoodcuttingTests.kt @@ -0,0 +1,88 @@ + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.apollo.cache.def.ItemDefinition +import org.apollo.game.model.entity.Player +import org.apollo.game.model.entity.Skill +import org.apollo.game.plugin.skills.woodcutting.Axe +import org.apollo.game.plugin.testing.assertions.after +import org.apollo.game.plugin.testing.assertions.contains +import org.apollo.game.plugin.testing.assertions.verifyAfter +import org.apollo.game.plugin.testing.junit.ApolloTestingExtension +import org.apollo.game.plugin.testing.junit.api.ActionCapture +import org.apollo.game.plugin.testing.junit.api.annotations.ItemDefinitions +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +import org.apollo.game.plugin.testing.junit.api.interactions.interactWithObject +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ArgumentsSource +import java.util.* + +@ExtendWith(ApolloTestingExtension::class) +class WoodcuttingTests { + + @ItemDefinitions + fun logs() = woodcuttingTestData().map { + ItemDefinition(it.tree.id).also { it.name = "" } + } + + @ItemDefinitions + fun tools() = listOf(ItemDefinition(Axe.BRONZE.id)) + + @TestMock + lateinit var action: ActionCapture + + @TestMock + lateinit var player: Player + + @ParameterizedTest + @ArgumentsSource(WoodcuttingTestDataProvider::class) + fun `Attempting to cut a tree when the player has no axe should send a message`(data: WoodcuttingTestData) { + player.interactWithObject(data.treeId, 1) + + verify { player.sendMessage(contains("do not have an axe")) } + } + + @ParameterizedTest + @ArgumentsSource(WoodcuttingTestDataProvider::class) + fun `Attempting to cut a tree when the player is too low levelled should send a message`(data: WoodcuttingTestData) { + assumeTrue(data.tree.level > 1, "Normal trees are covered by axe requirements") + + player.inventory.add(Axe.BRONZE.id) + player.skillSet.setCurrentLevel(Skill.WOODCUTTING, data.tree.level - 1) + + player.interactWithObject(data.treeId, 1) + + verifyAfter(action.complete()) { player.sendMessage(contains("do not have the required level")) } + } + + @Disabled("Mocking constructors is not supported in mockk. Update WoodcuttingAction to pass a chance value") + @ParameterizedTest + @ArgumentsSource(WoodcuttingTestDataProvider::class) + fun `Cutting a tree we have the skill to cut should eventually reward logs and experience`( + data: WoodcuttingTestData + ) { + // Mock RNG instances used by mining internally to determine success + // @todo - improve this so we don't have to mock Random + val rng = mockk() + every { rng.nextInt(100) } answers { 0 } + + player.inventory.add(Axe.BRONZE.id) + player.skillSet.setCurrentLevel(Skill.WOODCUTTING, data.tree.level) + + player.interactWithObject(data.treeId, 1) + + verifyAfter(action.ticks(1)) { player.sendMessage(contains("You swing your axe")) } + + after(action.ticks(Axe.BRONZE.pulses)) { + // @todo - cummulative ticks() calls? + verify { player.sendMessage("You manage to cut some ") } + assertEquals(data.tree.exp, player.skillSet.getExperience(Skill.WOODCUTTING)) + assertEquals(1, player.inventory.getAmount(data.tree.id)) + } + } +} \ No newline at end of file diff --git a/game/plugin/util/lookup/build.gradle b/game/plugin/util/lookup/build.gradle new file mode 100644 index 00000000..e2d4a362 --- /dev/null +++ b/game/plugin/util/lookup/build.gradle @@ -0,0 +1,3 @@ +plugin { + name = "entity_lookup" +} \ No newline at end of file diff --git a/game/src/main/java/org/apollo/game/model/World.java b/game/src/main/java/org/apollo/game/model/World.java index 991a41fc..5d5adb0f 100644 --- a/game/src/main/java/org/apollo/game/model/World.java +++ b/game/src/main/java/org/apollo/game/model/World.java @@ -299,7 +299,7 @@ public final class World { playerRepository.add(player); players.put(NameUtil.encodeBase37(username), player); - logger.info("Registered player: " + player + " [count=" + playerRepository.size() + "]"); + logger.finest("Registered player: " + player + " [count=" + playerRepository.size() + "]"); } /** @@ -359,7 +359,7 @@ public final class World { region.removeEntity(player); playerRepository.remove(player); - logger.info("Unregistered player: " + player + " [count=" + playerRepository.size() + "]"); + logger.finest("Unregistered player: " + player + " [count=" + playerRepository.size() + "]"); } /** diff --git a/game/src/main/java/org/apollo/game/model/entity/Mob.java b/game/src/main/java/org/apollo/game/model/entity/Mob.java index 4e90d48f..ec09bb9c 100644 --- a/game/src/main/java/org/apollo/game/model/entity/Mob.java +++ b/game/src/main/java/org/apollo/game/model/entity/Mob.java @@ -235,6 +235,15 @@ public abstract class Mob extends Entity { return firstDirection; } + /** + * Gets the current action, if any, of this mob. + * + * @return The action. + */ + public final Action getAction() { + return action; + } + /** * Gets the index of this mob. * @@ -337,6 +346,15 @@ public abstract class Mob extends Entity { return definition.map(NpcDefinition::getSize).orElse(1); } + /** + * Check whether this mob has a current active {@link Action}. + * + * @return {@code true} if this mob has a non-null {@link Action}. + */ + public final boolean hasAction() { + return action != null; + } + /** * Returns whether or not this mob has an {@link NpcDefinition}. * diff --git a/game/src/main/kotlin/org/apollo/game/action/ActionCoroutine.kt b/game/src/main/kotlin/org/apollo/game/action/ActionCoroutine.kt index 1087142b..5c49c794 100644 --- a/game/src/main/kotlin/org/apollo/game/action/ActionCoroutine.kt +++ b/game/src/main/kotlin/org/apollo/game/action/ActionCoroutine.kt @@ -55,7 +55,7 @@ class ActionCoroutine : Continuation { * Create a new `ActionCoroutine` and immediately execute the given `block`, returning a continuation that * can be resumed. */ - fun start(block: ActionBlock) : ActionCoroutine { + fun start(block: ActionBlock): ActionCoroutine { val coroutine = ActionCoroutine() val continuation = block.createCoroutineUnchecked(coroutine, coroutine) @@ -95,10 +95,7 @@ class ActionCoroutine : Continuation { * Update this continuation and check if the condition for the next step to be resumed is satisfied. */ fun pulse() { - val nextStep = next.getAndSet(null) - if (nextStep == null) { - return - } + val nextStep = next.getAndSet(null) ?: return val condition = nextStep.condition val continuation = nextStep.continuation @@ -120,11 +117,13 @@ class ActionCoroutine : Continuation { /** * Stop execution of this continuation. */ - suspend fun stop() { - return suspendCancellableCoroutine(true) { cont -> + suspend fun stop(): Nothing { + suspendCancellableCoroutine(true) { cont -> next.set(null) cont.cancel() } + + error("Tried to resume execution a coroutine that should have been cancelled.") } /** diff --git a/game/src/test/kotlin/org/apollo/game/action/ActionCoroutineTest.kt b/game/src/test/kotlin/org/apollo/game/action/ActionCoroutineTest.kt index 4b6df03d..a8f165ce 100644 --- a/game/src/test/kotlin/org/apollo/game/action/ActionCoroutineTest.kt +++ b/game/src/test/kotlin/org/apollo/game/action/ActionCoroutineTest.kt @@ -1,7 +1,7 @@ package org.apollo.game.action -import org.apollo.game.action.ActionCoroutine -import org.junit.Assert.* +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class ActionCoroutineTest { @@ -29,7 +29,6 @@ class ActionCoroutineTest { fun `Coroutine cancels on stop() calls`() { val coroutine = ActionCoroutine.start { stop() - wait(1) } assertTrue(coroutine.stopped()) diff --git a/gradle/properties.gradle b/gradle/properties.gradle index a5b882c5..bf7c2557 100644 --- a/gradle/properties.gradle +++ b/gradle/properties.gradle @@ -11,4 +11,9 @@ ext { commonsCompressVersion = '1.10' assertjVersion = '3.8.0' classGraphVersion = '4.0.6' + junitVintageVersion = '5.1.0' + junitPlatformVersion = '1.1.0' + junitJupiterVersion = '5.1.0' + mockkVersion = '1.7.15' + assertkVersion = '0.9' } \ No newline at end of file