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