diff --git a/game/plugin/skills/fishing/src/fishing.plugin.kts b/game/plugin/skills/fishing/src/fishing.plugin.kts deleted file mode 100644 index 25054564..00000000 --- a/game/plugin/skills/fishing/src/fishing.plugin.kts +++ /dev/null @@ -1,128 +0,0 @@ - -import Fishing_plugin.FishingAction -import org.apollo.game.action.ActionBlock -import org.apollo.game.action.AsyncDistancedAction -import org.apollo.game.message.impl.NpcActionMessage -import org.apollo.game.model.Position -import org.apollo.game.model.entity.Player -import org.apollo.game.plugin.api.fishing -import org.apollo.game.plugin.api.rand -import org.apollo.game.plugin.skills.fishing.FishingSpot -import org.apollo.game.plugin.skills.fishing.FishingTool -import java.util.Objects - -// TODO: moving fishing spots, seaweed and caskets, evil bob - -/** - * Intercepts the [NpcActionMessage] and starts a [FishingAction] if the npc - */ -on { NpcActionMessage::class } - .where { option == 1 || option == 3 } - .then { player -> - val entity = player.world.npcRepository[index] - val spot = FishingSpot.lookup(entity.id) ?: return@then - - val option = spot.option(option) - player.startAction(FishingAction(player, entity.position, option)) - - terminate() - } - -class FishingAction(player: Player, position: Position, val option: FishingSpot.Option) : - AsyncDistancedAction(0, true, player, position, SPOT_DISTANCE) { - - /** - * The [FishingTool] used for the fishing spot. - */ - private val tool = option.tool - - override fun action(): ActionBlock = { - if (!verify()) { - stop() - } - - mob.turnTo(position) - mob.sendMessage(tool.message) - - while (isRunning) { - mob.playAnimation(tool.animation) - wait(FISHING_DELAY) - - val level = mob.fishing.current - val fish = option.sample(level) - - if (successfulCatch(level, fish.level)) { - if (tool.bait != -1) { - mob.inventory.remove(tool.bait) - } - - mob.inventory.add(fish.id) - mob.sendMessage(fish.catchMessage) - mob.fishing.experience += fish.experience - - if (mob.inventory.freeSlots() == 0) { - mob.inventory.forceCapacityExceeded() - - mob.stopAnimation() - stop() - } else if (!hasBait(mob, tool.bait)) { - mob.sendMessage("You need more ${tool.baitName} to fish at this spot.") - - mob.stopAnimation() - stop() - } - } - } - } - - /** - * Verifies that the player can gather fish from the [FishingSpot] they clicked. - */ - private fun verify(): Boolean { - val current = mob.fishing.current - - when { - current < option.level -> mob.sendMessage("You need a fishing level of ${option.level} to fish at this spot.") - !hasTool(mob, tool) -> mob.sendMessage("You need a ${tool.formattedName} to fish at this spot.") - !hasBait(mob, tool.bait) -> mob.sendMessage("You need some ${tool.baitName} to fish at this spot.") - mob.inventory.freeSlots() == 0 -> mob.inventory.forceCapacityExceeded() - else -> return true - } - - return false - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (javaClass != other?.javaClass) return false - - other as FishingAction - return option == other.option && position == other.position && mob == other.mob - } - - override fun hashCode(): Int = Objects.hash(option, position, mob) - - private companion object { - private const val SPOT_DISTANCE = 1 - private const val FISHING_DELAY = 4 - - /** - * Returns whether or not the catch was successful. - * TODO: We need to identify the correct algorithm for this - */ - private fun successfulCatch(level: Int, req: Int): Boolean = minOf(level - req + 5, 40) > rand(100) - - /** - * Returns whether or not the [Player] has (or does not need) bait. - */ - private fun hasBait(player: Player, bait: Int): Boolean = bait == -1 || player.inventory.contains(bait) - - /** - * Returns whether or not the player has the required tool to fish at the spot. - */ - private fun hasTool(player: Player, tool: FishingTool): Boolean = player.equipment.contains(tool.id) || - player.inventory.contains(tool.id) - - } - -} \ No newline at end of file diff --git a/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Fish.kt b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Fish.kt new file mode 100644 index 00000000..1a281943 --- /dev/null +++ b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Fish.kt @@ -0,0 +1,31 @@ +package org.apollo.game.plugin.skills.fishing + +import org.apollo.game.plugin.api.Definitions + +/** + * A fish that can be gathered using the fishing skill. + */ +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), + 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), + SALMON(id = 331, level = 30, experience = 70.0), + TUNA(id = 359, level = 35, experience = 80.0), + 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, catchSuffix = "a shark!"); + + /** + * The name of this fish, formatted so it can be inserted into a message. + */ + val catchMessage by lazy { "You catch ${catchSuffix ?: "a $catchName."}" } + + private val catchName by lazy { Definitions.item(id).name.toLowerCase().removePrefix("raw ") } + +} diff --git a/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Fishing.plugin.kts b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Fishing.plugin.kts new file mode 100644 index 00000000..597058bb --- /dev/null +++ b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Fishing.plugin.kts @@ -0,0 +1,20 @@ +package org.apollo.game.plugin.skills.fishing + +import org.apollo.game.message.impl.NpcActionMessage + +// TODO: moving fishing spots, seaweed and caskets, evil bob + +/** + * Intercepts the [NpcActionMessage] and starts a [FishingAction] if the npc + */ +on { NpcActionMessage::class } + .where { option == 1 || option == 3 } + .then { player -> + val entity = player.world.npcRepository[index] + val option = FishingSpot.lookup(entity.id)?.option(option) ?: return@then + + val target = FishingTarget(entity.position, option) + player.startAction(FishingAction(player, target)) + + terminate() + } diff --git a/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingAction.kt b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingAction.kt new file mode 100644 index 00000000..a02412e7 --- /dev/null +++ b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingAction.kt @@ -0,0 +1,88 @@ +package org.apollo.game.plugin.skills.fishing + +import org.apollo.game.action.ActionBlock +import org.apollo.game.action.AsyncDistancedAction +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.api.fishing +import java.util.Objects + +class FishingAction( + player: Player, + private val target: FishingTarget +) : AsyncDistancedAction(0, true, player, target.position, SPOT_DISTANCE) { + + /** + * The [FishingTool] used for the fishing spot. + */ + private val tool = target.option.tool + + override fun action(): ActionBlock = { + if (!target.verify(mob)) { + stop() + } + + mob.turnTo(position) + mob.sendMessage(tool.message) + + while (isRunning) { + mob.playAnimation(tool.animation) + wait(FISHING_DELAY) + + val level = mob.fishing.current + val fish = target.option.sample(level) + + if (target.isSuccessful(mob, fish.level)) { + if (tool.bait != -1) { + mob.inventory.remove(tool.bait) + } + + mob.inventory.add(fish.id) + mob.sendMessage(fish.catchMessage) + mob.fishing.experience += fish.experience + + if (mob.inventory.freeSlots() == 0) { + mob.inventory.forceCapacityExceeded() + + mob.stopAnimation() + stop() + } else if (!hasBait(mob, tool.bait)) { + mob.sendMessage("You need more ${tool.baitName} to fish at this spot.") + + mob.stopAnimation() + stop() + } + } + } + } + + override fun equals(other: Any?): Boolean { + if (other is FishingAction) { + return position == other.position && target == other.target && mob == other.mob + } + + return false + } + + override fun hashCode(): Int = Objects.hash(target, position, mob) + + internal companion object { + private const val SPOT_DISTANCE = 1 + private const val FISHING_DELAY = 4 + + /** + * Returns whether or not the [Player] has (or does not need) bait. + */ + internal fun hasBait(player: Player, bait: Int): Boolean { + return bait == -1 || bait in player.inventory + } + + /** + * Returns whether or not the player has the required tool to fish at the spot. + */ + internal fun hasTool(player: Player, tool: FishingTool): Boolean { + return tool.id in player.equipment || tool.id in player.inventory + } + + } + +} \ No newline at end of file diff --git a/game/plugin/skills/fishing/src/fishing.kt b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingSpot.kt similarity index 51% rename from game/plugin/skills/fishing/src/fishing.kt rename to game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingSpot.kt index ccf58455..54a54298 100644 --- a/game/plugin/skills/fishing/src/fishing.kt +++ b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingSpot.kt @@ -1,76 +1,36 @@ package org.apollo.game.plugin.skills.fishing -import org.apollo.game.model.Animation -import org.apollo.game.plugin.api.Definitions import org.apollo.game.plugin.api.rand import org.apollo.game.plugin.skills.fishing.Fish.* -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, 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), - 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), - SALMON(id = 331, level = 30, experience = 70.0), - TUNA(id = 359, level = 35, experience = 80.0), - 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, catchSuffix = "a shark!"); - - /** - * The name of this fish, formatted so it can be inserted into a message. - */ - val catchMessage by lazy { "You catch ${catchSuffix ?: "a ${catchName()}."}" } - - private fun catchName() = Definitions.item(id).name.toLowerCase().removePrefix("raw ") - -} - -/** - * A tool used to gather [Fish] from a [FishingSpot]. - */ -enum class FishingTool( - val message: String, - val id: Int, - animation: Int, - val bait: Int = -1, - val baitName: String? = null -) { - LOBSTER_CAGE("You attempt to catch a lobster...", id = 301, animation = 619), - SMALL_NET("You cast out your net...", id = 303, animation = 620), - BIG_NET("You cast out your net...", id = 305, animation = 620), - HARPOON("You start harpooning fish...", id = 311, animation = 618), - FISHING_ROD("You attempt to catch a fish...", id = 307, animation = 622, bait = 313, baitName = "feathers"), - FLY_FISHING_ROD("You attempt to catch a fish...", id = 309, animation = 622, bait = 314, baitName = "fishing bait"); - - /** - * The [Animation] played when fishing with this tool. - */ - val animation: Animation = Animation(animation) - - /** - * The name of this tool, formatted so it can be inserted into a message. - */ - val formattedName by lazy { Definitions.item(id).name.toLowerCase() } - -} /** * A spot that can be fished from. */ enum class FishingSpot(val npc: Int, private val first: Option, private val second: Option) { - 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, SHRIMPS, ANCHOVIES), Option.of(FISHING_ROD, SARDINE, HERRING)); + + ROD( + npc = 309, + first = Option.of(tool = FishingTool.FLY_FISHING_ROD, primary = TROUT, secondary = SALMON), + second = Option.of(tool = FishingTool.FISHING_ROD, primary = PIKE) + ), + + CAGE_HARPOON( + npc = 312, + first = Option.of(tool = FishingTool.LOBSTER_CAGE, primary = LOBSTER), + second = Option.of(tool = FishingTool.HARPOON, primary = TUNA, secondary = SWORDFISH) + ), + + NET_HARPOON( + npc = 313, + first = Option.of(tool = FishingTool.BIG_NET, primary = MACKEREL, secondary = COD), + second = Option.of(tool = FishingTool.HARPOON, primary = BASS, secondary = SHARK) + ), + + NET_ROD( + npc = 316, + first = Option.of(tool = FishingTool.SMALL_NET, primary = SHRIMPS, secondary = ANCHOVIES), + second = Option.of(tool = FishingTool.FISHING_ROD, primary = SARDINE, secondary = HERRING) + ); /** * Returns the [FishingSpot.Option] associated with the specified action id. @@ -160,4 +120,4 @@ enum class FishingSpot(val npc: Int, private val first: Option, private val seco fun lookup(id: Int): FishingSpot? = FISHING_SPOTS[id] } -} +} \ No newline at end of file diff --git a/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingTarget.kt b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingTarget.kt new file mode 100644 index 00000000..7275ee63 --- /dev/null +++ b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingTarget.kt @@ -0,0 +1,39 @@ +package org.apollo.game.plugin.skills.fishing + +import org.apollo.game.model.Position +import org.apollo.game.model.entity.Player +import org.apollo.game.plugin.api.fishing +import org.apollo.game.plugin.api.rand +import org.apollo.game.plugin.skills.fishing.FishingAction.Companion.hasBait +import org.apollo.game.plugin.skills.fishing.FishingAction.Companion.hasTool + +data class FishingTarget(val position: Position, val option: FishingSpot.Option) { + + /** + * Returns whether or not the catch was successful. + * TODO: We need to identify the correct algorithm for this + */ + fun isSuccessful(player: Player, req: Int): Boolean { + return minOf(player.fishing.current - req + 5, 40) > rand(100) + } + + /** + * Verifies that the [Player] can gather fish from their chosen [FishingSpot.Option]. + */ + fun verify(player: Player): Boolean { + val current = player.fishing.current + val required = option.level + val tool = option.tool + + when { + current < required -> player.sendMessage("You need a fishing level of $required to fish at this spot.") + hasTool(player, tool) -> player.sendMessage("You need a ${tool.formattedName} to fish at this spot.") + hasBait(player, tool.bait) -> player.sendMessage("You need some ${tool.baitName} to fish at this spot.") + player.inventory.freeSlots() == 0 -> player.inventory.forceCapacityExceeded() + else -> return true + } + + return false + } + +} \ No newline at end of file diff --git a/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingTool.kt b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingTool.kt new file mode 100644 index 00000000..41f639d0 --- /dev/null +++ b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/FishingTool.kt @@ -0,0 +1,33 @@ +package org.apollo.game.plugin.skills.fishing + +import org.apollo.game.model.Animation +import org.apollo.game.plugin.api.Definitions + +/** + * A tool used to gather [Fish] from a [FishingSpot]. + */ +enum class FishingTool( + val message: String, + val id: Int, + animation: Int, + val bait: Int = -1, + val baitName: String? = null +) { + LOBSTER_CAGE("You attempt to catch a lobster...", id = 301, animation = 619), + SMALL_NET("You cast out your net...", id = 303, animation = 620), + BIG_NET("You cast out your net...", id = 305, animation = 620), + HARPOON("You start harpooning fish...", id = 311, animation = 618), + FISHING_ROD("You attempt to catch a fish...", id = 307, animation = 622, bait = 313, baitName = "feathers"), + FLY_FISHING_ROD("You attempt to catch a fish...", id = 309, animation = 622, bait = 314, baitName = "fishing bait"); + + /** + * The [Animation] played when fishing with this tool. + */ + val animation: Animation = Animation(animation) + + /** + * The name of this tool, formatted so it can be inserted into a message. + */ + val formattedName by lazy { Definitions.item(id).name.toLowerCase() } + +} \ No newline at end of file diff --git a/game/plugin/skills/fishing/src/spots.plugin.kts b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Spots.plugin.kts similarity index 96% rename from game/plugin/skills/fishing/src/spots.plugin.kts rename to game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Spots.plugin.kts index e874ad85..3d162459 100644 --- a/game/plugin/skills/fishing/src/spots.plugin.kts +++ b/game/plugin/skills/fishing/src/org/apollo/game/plugin/skills/fishing/Spots.plugin.kts @@ -1,7 +1,6 @@ -import org.apollo.game.model.Direction +package org.apollo.game.plugin.skills.fishing import org.apollo.game.model.Position import org.apollo.game.plugin.entity.spawn.spawnNpc -import org.apollo.game.plugin.skills.fishing.FishingSpot import org.apollo.game.plugin.skills.fishing.FishingSpot.CAGE_HARPOON import org.apollo.game.plugin.skills.fishing.FishingSpot.NET_HARPOON import org.apollo.game.plugin.skills.fishing.FishingSpot.NET_ROD @@ -177,5 +176,5 @@ NET_ROD at Position(3103, 3092) * Registers the [FishingSpot] at the specified position. */ infix fun FishingSpot.at(position: Position) { - spawnNpc("", position.x, position.y, position.height, id = npc, facing = Direction.NORTH) + spawnNpc("", position, id = npc) } \ No newline at end of file diff --git a/game/plugin/skills/fishing/test/org/apollo/game/plugin/skills/fishing/FishingActionTests.kt b/game/plugin/skills/fishing/test/org/apollo/game/plugin/skills/fishing/FishingActionTests.kt new file mode 100644 index 00000000..10f90abb --- /dev/null +++ b/game/plugin/skills/fishing/test/org/apollo/game/plugin/skills/fishing/FishingActionTests.kt @@ -0,0 +1,93 @@ +package org.apollo.game.plugin.skills.fishing + +import io.mockk.every +import io.mockk.spyk +import io.mockk.verify +import org.apollo.cache.def.ItemDefinition +import org.apollo.cache.def.NpcDefinition +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.assertions.contains +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.NpcDefinitions +import org.apollo.game.plugin.testing.junit.api.annotations.TestMock +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.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 FishingActionTests { + + @TestMock + lateinit var world: World + + @TestMock + lateinit var player: Player + + @TestMock + lateinit var action: ActionCapture + + @Test + fun `Attempting to fish at a spot we don't have the skill to should send the player a message`() { + val obj = world.spawnObject(1, player.position) + + val option = spyk(FishingSpot.CAGE_HARPOON.option(1)) + val target = FishingTarget(obj.position, option) + + player.startAction(FishingAction(player, target)) + + every { option.level } returns Int.MAX_VALUE + + verifyAfter(action.complete()) { + player.sendMessage(contains("need a fishing level of ${Int.MAX_VALUE}")) + } + } + + @Test + fun `Fishing at a spot we have the skill to should eventually reward fish and experience`() { + val option = spyk(FishingSpot.CAGE_HARPOON.option(1)) + val obj = world.spawnNpc(FishingSpot.CAGE_HARPOON.npc, player.position) + + val target = spyk(FishingTarget(obj.position, option)) + every { target.isSuccessful(player, any()) } returns true + every { target.verify(player) } returns true + + player.skillSet.setCurrentLevel(Skill.FISHING, option.level) + player.startAction(FishingAction(player, target)) + + verifyAfter(action.ticks(1)) { + player.sendMessage(startsWith("You attempt to catch a lobster")) + } + + after(action.ticks(4)) { + verify { player.sendMessage(startsWith("You catch a .")) } + + assertTrue(player.inventory.contains(Fish.LOBSTER.id)) + assertEquals(player.skillSet.getExperience(Skill.FISHING), Fish.LOBSTER.experience) + } + } + + private companion object { + @ItemDefinitions + private val fish = Fish.values() + .map { ItemDefinition(it.id).apply { name = "" } } + + @ItemDefinitions + private val tools = FishingTool.values() + .map { ItemDefinition(it.id).apply { name = "" } } + + @NpcDefinitions + private val spots = FishingSpot.values() + .map { NpcDefinition(it.npc).apply { name = "" } } + } + +} \ No newline at end of file