From cd42073f90d8ac806e0fbdbfd396dc496e475498 Mon Sep 17 00:00:00 2001 From: Seelad Date: Tue, 28 Jul 2026 20:45:20 -0400 Subject: [PATCH 1/5] Crafting implementation. --- .data/raw-cache/server/loc/loc.toml | 30 + .data/raw-cache/server/npcs.toml | 24 + .../net/rsprot/handlers/If1ButtonHandler.kt | 69 + .../provider/MessageConsumerProvider.kt | 4 + content/skills/crafting/build.gradle.kts | 15 + .../content/skills/crafting/CraftingApi.kt | 154 + .../skills/crafting/CraftingGuildBank.kt | 38 + .../skills/crafting/CraftingGuildDoor.kt | 93 + .../crafting/CraftingGuildRequirements.kt | 29 + .../skills/crafting/CraftingProduct.kt | 373 +++ .../skills/crafting/CraftingSection.kt | 329 +++ .../content/skills/crafting/CraftingWorker.kt | 491 ++++ .../interfaces/GoldCraftingInterface.kt | 174 ++ .../crafting/interfaces/MakeQuantity.kt | 84 + .../interfaces/SilverCraftingInterface.kt | 119 + .../crafting/interfaces/TannerInterface.kt | 247 ++ .../crafting/items/BucketOfSandScript.kt | 16 + .../crafting/items/CraftingCapeScript.kt | 86 + .../crafting/items/ImcandoHammerScript.kt | 57 + .../npcs/CraftingGuildMasterCrafter.kt | 183 ++ .../crafting/npcs/CraftingTutorScript.kt | 321 ++ .../skills/crafting/npcs/EodanScript.kt | 168 ++ .../crafting/npcs/LeatherTannerScript.kt | 161 + .../skills/crafting/npcs/MaryScript.kt | 130 + .../skills/crafting/npcs/SbottScript.kt | 127 + .../skills/crafting/npcs/ThakkradScript.kt | 132 + .../scripts/CraftingCommandsScript.kt | 63 + .../scripts/FacilityCraftingScript.kt | 195 ++ .../crafting/scripts/HeldCraftingScript.kt | 20 + .../crafting/scripts/JewelleryScript.kt | 20 + .../skills/crafting/scripts/SandPitScript.kt | 30 + .../skills/crafting/util/CraftingConfig.kt | 6 + .../skills/crafting/util/CraftingConstants.kt | 190 ++ .../skills/crafting/util/CraftingGamevals.kt | 72 + .../skills/crafting/util/CraftingGates.kt | 44 + .../skills/crafting/util/CraftingTools.kt | 47 + .../crafting/src/main/resources/gamevals.toml | 267 ++ content/skills/smithing/build.gradle.kts | 1 + .../smithing/smelting/SmeltingScript.kt | 8 + .../main/kotlin/dev/openrune/CacheTools.kt | 5 + .../dev/openrune/codegen/TableGenerater.kt | 7 + .../dev/openrune/tables/skills/Crafting.kt | 2598 +++++++++++++++++ 42 files changed, 7227 insertions(+) create mode 100644 api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/If1ButtonHandler.kt create mode 100644 content/skills/crafting/build.gradle.kts create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingApi.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildBank.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/MakeQuantity.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/SilverCraftingInterface.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/BucketOfSandScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/CraftingCapeScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/ImcandoHammerScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingTutorScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/ThakkradScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/CraftingCommandsScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/FacilityCraftingScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/JewelleryScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/SandPitScript.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConfig.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGamevals.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGates.kt create mode 100644 content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingTools.kt create mode 100644 content/skills/crafting/src/main/resources/gamevals.toml create mode 100644 or-cache/src/main/kotlin/dev/openrune/tables/skills/Crafting.kt diff --git a/.data/raw-cache/server/loc/loc.toml b/.data/raw-cache/server/loc/loc.toml index 595ef9415..59d785c9d 100644 --- a/.data/raw-cache/server/loc/loc.toml +++ b/.data/raw-cache/server/loc/loc.toml @@ -1700,3 +1700,33 @@ contentGroup = "content.shooting_star" id = "loc.star_size_one_star" inherit = "loc.star_size_one_star" contentGroup = "content.shooting_star" + +[[object]] +id = "loc.sandpit" +inherit = "loc.sandpit" +contentGroup = "content.sandpit" + +[[object]] +id = "loc.prif_sandpit" +inherit = "loc.prif_sandpit" +contentGroup = "content.sandpit" + +[[object]] +id = "loc.sandpit_2" +inherit = "loc.sandpit_2" +contentGroup = "content.sandpit" + +[[object]] +id = "loc.viking_sandpit_noop" +inherit = "loc.viking_sandpit_noop" +contentGroup = "content.sandpit" + +[[object]] +id = "loc.sarim_spiralstairs" +inherit = "loc.sarim_spiralstairs" +contentGroup = "content.spiralstaircase_up" + +[[object]] +id = "loc.sarim_spiralstairstop" +inherit = "loc.sarim_spiralstairstop" +contentGroup = "content.spiralstaircase_down" diff --git a/.data/raw-cache/server/npcs.toml b/.data/raw-cache/server/npcs.toml index 2676c7526..e9a2f9b9e 100644 --- a/.data/raw-cache/server/npcs.toml +++ b/.data/raw-cache/server/npcs.toml @@ -3233,3 +3233,27 @@ inherit = "npc.wyvern_taloned" [npc.params] "param.killcount_varp"="varp.kc_taloned_wyvern" "param.killcount_notify"=false + +[[npc]] +id = "npc.ellis_tanner" +inherit = "npc.ellis_tanner" +moveRestrict = "Indoors" +wanderRange = 5 + +[[npc]] +id = "npc.master_crafter" +inherit = "npc.master_crafter" +moveRestrict = "NoMove" +wanderRange = 0 + +[[npc]] +id = "npc.master_crafter_2" +inherit = "npc.master_crafter_2" +moveRestrict = "NoMove" +wanderRange = 0 + +[[npc]] +id = "npc.master_crafter_3" +inherit = "npc.master_crafter_3" +moveRestrict = "NoMove" +wanderRange = 0 diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/If1ButtonHandler.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/If1ButtonHandler.kt new file mode 100644 index 000000000..be5d2afa7 --- /dev/null +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/handlers/If1ButtonHandler.kt @@ -0,0 +1,69 @@ +package org.rsmod.api.net.rsprot.handlers + +import com.github.michaelbull.logging.InlineLogger +import dev.openrune.ServerCacheManager +import dev.openrune.definition.type.widget.IfEvent +import dev.openrune.types.aconverted.interf.IfButtonOp +import jakarta.inject.Inject +import net.rsprot.protocol.game.incoming.buttons.If1Button +import org.rsmod.annotations.InternalApi +import org.rsmod.api.net.rsprot.player.InterfaceEvents +import org.rsmod.api.player.protect.ProtectedAccessLauncher +import org.rsmod.api.player.ui.IfModalButton +import org.rsmod.api.player.ui.IfOverlayButton +import org.rsmod.api.player.ui.ifCloseInputDialog +import org.rsmod.events.EventBus +import org.rsmod.game.entity.Player +import org.rsmod.game.ui.Component + +/** + * Handles clicks on legacy if1-format components (e.g. the tanner interface). + * + * Unlike [If3Button][net.rsprot.protocol.game.incoming.buttons.If1Button]'s if3 sibling, + * the if1 packet carries only the component (no op number, no comsub, and no obj). + * + * To account for that, incoming clicks are normalized to [IfButtonOp.Op1] with comsub -1 and + * published as the same [IfModalButton]/[IfOverlayButton] events the if3 handler produces, + * so content scripts handle both formats through the same `onIfModalButton`/`onIfOverlayButton` registration. + */ +class If1ButtonHandler +@Inject +constructor(private val eventBus: EventBus, private val protectedAccess: ProtectedAccessLauncher) : + MessageHandler { + private val logger = InlineLogger() + + private val If1Button.asComponent: Component + get() = Component(interfaceId, componentId) + + @OptIn(InternalApi::class) + override fun handle(player: Player, message: If1Button) { + val componentType = ServerCacheManager.fromComponent(message.asComponent.packed) + val interfaceType = ServerCacheManager.fromInterface(message.asComponent.packed) + + val opEnabled = + componentType.hasEvent(IfEvent.Op1) || + InterfaceEvents.isEnabled(player.ui, componentType, 0, IfEvent.Op1) + if (!opEnabled) { + logger.debug { "[Gated] If1Button: $message" } + return + } + + if (player.ui.containsOverlay(interfaceType) || player.ui.containsTopLevel(interfaceType)) { + val event = IfOverlayButton(componentType, comsub = -1, obj = null, op = IfButtonOp.Op1) + logger.debug { "[Overlay] If1Button: $message (event=$event)" } + protectedAccess.launchLenient(player) { eventBus.publish(this, event) } + return + } + + if (player.ui.containsModal(interfaceType)) { + val event = IfModalButton(componentType, comsub = -1, obj = null, op = IfButtonOp.Op1) + player.ifCloseInputDialog() + if (player.isModalButtonProtected) { + logger.debug { "[Modal][BLOCKED] If1Button: $message (event=$event)" } + return + } + logger.debug { "[Modal] If1Button: $message (event=$event)" } + protectedAccess.launchLenient(player) { eventBus.publish(this, event) } + } + } +} diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt index 6b970ebe6..2ce1172ce 100644 --- a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/provider/MessageConsumerProvider.kt @@ -3,6 +3,7 @@ package org.rsmod.api.net.rsprot.provider import jakarta.inject.Inject import jakarta.inject.Singleton import kotlin.jvm.java +import net.rsprot.protocol.game.incoming.buttons.If1Button import net.rsprot.protocol.game.incoming.buttons.If3Button import net.rsprot.protocol.game.incoming.buttons.IfButtonD import net.rsprot.protocol.game.incoming.buttons.IfButtonT @@ -44,6 +45,7 @@ import org.rsmod.api.net.rsprot.handlers.ClientCheatHandler import org.rsmod.api.net.rsprot.handlers.CloseModalHandler import org.rsmod.api.net.rsprot.handlers.FriendListAddHandler import org.rsmod.api.net.rsprot.handlers.FriendListDeleteHandler +import org.rsmod.api.net.rsprot.handlers.If1ButtonHandler import org.rsmod.api.net.rsprot.handlers.If3ButtonHandler import org.rsmod.api.net.rsprot.handlers.IfButtonDHandler import org.rsmod.api.net.rsprot.handlers.IfButtonTHandler @@ -99,6 +101,7 @@ constructor( private val ignoreListAdd: IgnoreListAddHandler, private val ignoreListDelete: IgnoreListDeleteHandler, private val setChatFilterSettings: SetChatFilterSettingsHandler, + private val if1Button: If1ButtonHandler, private val if3Button: If3ButtonHandler, private val closeModal: CloseModalHandler, private val resumePauseButton: ResumePauseButtonHandler, @@ -136,6 +139,7 @@ constructor( builder.addListener(IgnoreListAdd::class.java, ignoreListAdd) builder.addListener(IgnoreListDel::class.java, ignoreListDelete) builder.addListener(SetChatFilterSettings::class.java, setChatFilterSettings) + builder.addListener(If1Button::class.java, if1Button) builder.addListener(If3Button::class.java, if3Button) builder.addListener(CloseModal::class.java, closeModal) builder.addListener(ResumePauseButton::class.java, resumePauseButton) diff --git a/content/skills/crafting/build.gradle.kts b/content/skills/crafting/build.gradle.kts new file mode 100644 index 000000000..0d8f91080 --- /dev/null +++ b/content/skills/crafting/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + id("base-conventions") +} + +dependencies { + implementation(projects.api.attr) + implementation(projects.api.combat.combatManager) + implementation(projects.api.player) + implementation(projects.api.pluginCommons) + implementation(projects.api.script) + implementation(projects.api.spells) + implementation(projects.content.generic.genericLocs) + implementation(projects.content.quest) + implementation(projects.content.skills.utils) +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingApi.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingApi.kt new file mode 100644 index 000000000..aeaad1841 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingApi.kt @@ -0,0 +1,154 @@ +package org.rsmod.content.skills.crafting + +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpHeldU +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.meetsUnlocks +import org.rsmod.content.skills.crafting.util.toolEquivalents +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Every recipe the module knows about, keyed by the obj it produces. [craftingProduct] populates + * this itself, so recipes registered by other modules through the same builder show up too. + */ +object CraftingRecipes { + private val byOutput = LinkedHashMap>() + + /** Called by [craftingProduct] for every recipe built. */ + internal fun register(product: CraftingProduct) { + byOutput.getOrPut(product.output) { LinkedHashSet() } += product + } + + /** Recipes producing [output]. Some objs have more than one, such as bow string. */ + fun forOutput(output: String): List = byOutput[output]?.toList() ?: emptyList() + + /** Every obj the module can craft. */ + fun outputs(): Set = byOutput.keys + + /** + * Everything needed to make [crafts] of [output]. This will be the consumed inputs, any thread, and + * the recipe's tools. Returns null when nothing crafts [output]. Passing a [player] picks the + * variant they could actually craft, which is important if the desired output has a quest requirement. + */ + fun materialsFor( + output: String, + crafts: Int, + player: Player? = null, + ): List? { + val recipes = forOutput(output) + val usable = player?.let { p -> recipes.firstOrNull { p.meetsUnlocks(it) } } + val product = usable ?: recipes.firstOrNull() ?: return null + + val inputs = product.inputs.map { CraftingMaterial(it.internal, it.count * crafts) } + val tools = product.tools.map { CraftingMaterial(it, count = 1, tool = true) } + return inputs + threadFor(product, crafts) + tools + } + + /** Counts the thread spools required for the provided inputs. */ + private fun threadFor(product: CraftingProduct, crafts: Int): List { + if (!product.consumesThread) { + return emptyList() + } + val perSpool = CraftingConstants.THREAD_USES_PER_SPOOL + val spools = (crafts + perSpool - 1) / perSpool + return listOf(CraftingMaterial(CraftingConstants.THREAD, spools)) + } +} + +/** One line of a recipe's shopping list. A [tool] is required but never consumed. */ +data class CraftingMaterial(val obj: String, val count: Int, val tool: Boolean = false) + +/** + * Registers [products] as held (inventory) crafting recipes, one `onOpHeldU` per click pair. + * + * A recipe's click targets are its [CraftingProduct.triggers], or its inputs when it names none. + * It starts from any target used on any other, or from any of its tools used on any target, in + * either order. Tools match through [toolEquivalents], so a stand-in works as well as the named + * one. A birdhouse therefore starts from log on clockwork, hammer on log, or chisel on clockwork. + * + * Recipes sharing a pair register once, and the click resolves to whichever of them the player can + * actually make. Other modules can register their own recipes here after building them with + * [craftingProduct]. + */ +fun ScriptContext.registerHeldCrafting( + products: List, + combine: suspend ProtectedAccess.(CraftingProduct) -> Unit = { craftInstantly(it) }, +) { + val registrations = LinkedHashMap, PairRegistration>() + for (product in products) { + for (pair in product.clickPairs()) { + val (first, second) = pair + val reg = registrations.getOrPut(setOf(first, second)) { PairRegistration(first, second) } + if (product.ownsDefaultHandler(first) && reg.first != first) { + reg.first = first + reg.second = second + } + if (product !in reg.products) { + reg.products += product + } + } + } + for (reg in registrations.values) { + onOpHeldU(reg.first, reg.second) { craftFromClick(reg.products, combine) } + } +} + +fun ScriptContext.registerHeldCrafting( + product: CraftingProduct, + combine: suspend ProtectedAccess.(CraftingProduct) -> Unit = { craftInstantly(it) }, +): Unit = registerHeldCrafting(listOf(product), combine) + +private class PairRegistration(var first: String, var second: String) { + val products = mutableListOf() +} + +/** Every click pair that should start this recipe. */ +private fun CraftingProduct.clickPairs(): List> { + val ingredients = triggers.ifEmpty { inputs.map { it.internal } }.distinct() + val pairs = mutableListOf>() + for (i in ingredients.indices) { + for (j in i + 1 until ingredients.size) { + pairs += orderPair(ingredients[i], ingredients[j]) + } + } + for (tool in tools) { + for (trigger in toolEquivalents(tool)) { + for (ingredient in ingredients) { + pairs += orderPair(trigger, ingredient) + } + } + } + return pairs +} + +/** Keys a pair under whichever obj owns a competing default handler. */ +private fun CraftingProduct.orderPair(a: String, b: String): Pair = if (ownsDefaultHandler(b) && !ownsDefaultHandler(a)) b to a else a to b + +private fun CraftingProduct.ownsDefaultHandler(obj: String): Boolean = section.ownsDefaultHandler(obj) && inputs.any { it.internal == obj } + +/** Resolves which recipe a click pair meant, then starts it the way its section says to. */ +private suspend fun ProtectedAccess.craftFromClick( + candidates: List, + combine: suspend ProtectedAccess.(CraftingProduct) -> Unit, +) { + val unlocked = candidates.filter { player.meetsUnlocks(it) } + if (unlocked.isEmpty()) { + candidates.firstNotNullOfOrNull { it.lockedMessage }?.let { mesbox(it) } + return + } + // Falling back to the first lets the craft fail with its own "you need" message rather than + // the click doing nothing at all. + val product = unlocked.singleOrNull() + ?: unlocked.firstOrNull { hasCraftingMaterials(it) } + ?: unlocked.first() + + val mode = product.section.mode + val sameSection = unlocked.filter { it.section === product.section } + when { + mode == CraftingMode.COMBINE -> combine(product) + mode == CraftingMode.INSTANT -> craftInstantly(product) + product.ticksAt(0) <= 0 -> craftInstantly(product) + else -> selectCraftingProduct(product.section, sameSection) + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildBank.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildBank.kt new file mode 100644 index 000000000..2b76d7685 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildBank.kt @@ -0,0 +1,38 @@ +package org.rsmod.content.skills.crafting + +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpLoc1 +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class CraftingGuildBank : PluginScript() { + override fun ScriptContext.startup() { + onOpLoc1("loc.diary_guild_bankchest") { useBankChest() } + onOpLoc1("loc.diary_guild_deposit_box") { useDepositBox() } + } + + private suspend fun ProtectedAccess.useBankChest() { + arriveDelay() + if (!requirementMet()) { + return + } + ifOpenMainSidePair(main = "interface.bankmain", side = "interface.bankside") + } + + private suspend fun ProtectedAccess.useDepositBox() { + arriveDelay() + if (!requirementMet()) { + return + } + ifOpenMainModal("interface.bank_depositbox") + } + + private fun ProtectedAccess.requirementMet(): Boolean { + if (player.canUseGuildBank()) { + return true + } + mes("Only master crafters or those who have completed the hard or elite tier of the Falador Diary may use this.") + return false + } + +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt new file mode 100644 index 000000000..0f2c327ba --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt @@ -0,0 +1,93 @@ +package org.rsmod.content.skills.crafting + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.craftingLvl +import org.rsmod.api.repo.loc.LocRepository +import org.rsmod.api.script.onOpLoc1 +import org.rsmod.content.generic.locs.doors.DoorTranslations +import org.rsmod.game.loc.BoundLocInfo +import org.rsmod.game.map.Direction +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class CraftingGuildDoor @Inject constructor(private val locRepo: LocRepository) : PluginScript() { + override fun ScriptContext.startup() { + onOpLoc1(GUILD_DOOR) { + val door = it.vis + // The door sits on the outside tile, so we can check against that to determine if the player is leaving or entering. + if (coords.z < door.coords.z) { + walkThroughDoor(door) + return@onOpLoc1 + } + + when { + !player.hasGuildEntryOutfit() -> + denyEntry { + chatNpcSpecific( + title = MASTER_CRAFTER_TITLE, + type = MASTER_CRAFTER_NPC, + mesanim = neutral, + text = "Where's your brown apron? You can't come in here unless you're wearing one.", + ) + chatPlayer(neutral, "Err... I haven't got one.") + } + + player.craftingLvl < GUILD_ENTRY_LEVEL -> + denyEntry { + chatNpcSpecific( + title = MASTER_CRAFTER_TITLE, + type = MASTER_CRAFTER_NPC, + mesanim = neutral, + text = "Sorry, only experienced crafters are allowed in here. You must be " + + "level 40 or above to enter.", + ) + } + + else -> { + walkThroughDoor(door) + startDialogue { + chatNpcSpecific( + title = MASTER_CRAFTER_TITLE, + type = MASTER_CRAFTER_NPC, + mesanim = happy, + text = "Welcome to the Guild of Master Craftsmen.", + ) + } + } + } + } + } + + private suspend fun ProtectedAccess.denyEntry(lines: suspend Dialogue.() -> Unit) { + startDialogue { lines() } + } + + private suspend fun ProtectedAccess.walkThroughDoor(door: BoundLocInfo) { + val doorCoords = door.coords + val leaving = coords.z < doorCoords.z + val walkTo = if (leaving) doorCoords else doorCoords.translateZ(-1) + + val openAngle = door.turnAngle(rotations = 1) + val openCoords = DoorTranslations.translateOpen(doorCoords, door.shape, door.angle) + + locRepo.del(door, 3) + locRepo.add(openCoords, GUILD_DOOR_OPEN, 3, openAngle, door.shape) + + teleport(walkTo) + faceDirection(if (leaving) Direction.North else Direction.South) + + // Holds the player so they cannot click back and clip through it as it shuts. + delay(2) + } + + private companion object { + private const val GUILD_ENTRY_LEVEL = 40 + private const val MASTER_CRAFTER_TITLE = "Master Crafter" + private const val MASTER_CRAFTER_NPC = "npc.master_crafter" + + private const val GUILD_DOOR = "loc.craftingguilddoor" + private const val GUILD_DOOR_OPEN = "loc.inactivepoordoor" + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt new file mode 100644 index 000000000..b3f77a5e8 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt @@ -0,0 +1,29 @@ +package org.rsmod.content.skills.crafting + +import dev.openrune.ServerCacheManager +import org.rsmod.api.player.back +import org.rsmod.api.player.stat.baseCraftingLvl +import org.rsmod.api.player.vars.boolVarBit +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.game.entity.Player +import org.rsmod.game.inv.InvObj + +private val Player.faladorHardDiaryComplete by boolVarBit("varbit.falador_diary_hard_complete") +private val Player.faladorEliteDiaryComplete by boolVarBit("varbit.falador_diary_elite_complete") + +private fun Player.wearingMaxCape(): Boolean = CraftingConstants.MAX_SKILLCAPES.any { it in worn } + +internal fun Player.wearingCraftingSkillcape(): Boolean = CraftingConstants.CRAFTING_SKILLCAPES.any { it in worn } + +internal fun Player.wearingCraftingApron(): Boolean = CraftingConstants.GUILD_APRONS.any { it in worn } + +internal fun Player.ownsCraftingSkillcape(): Boolean = CraftingConstants.CRAFTING_SKILLCAPES.any { it in inv || it in worn } + +internal fun Player.ownsCraftingHood(): Boolean = "obj.skillcape_crafting_hood" in inv || "obj.skillcape_crafting_hood" in worn + +internal fun Player.hasGuildEntryOutfit(): Boolean = wearingCraftingApron() || wearingCraftingSkillcape() || wearingMaxCape() + +internal fun Player.canUseGuildBank(): Boolean = baseCraftingLvl >= CraftingConstants.MAX_CRAFTING_LEVEL || hasFaladorHardDiary() || hasFaladorEliteDiary() + +internal fun Player.hasFaladorHardDiary(): Boolean = faladorHardDiaryComplete +internal fun Player.hasFaladorEliteDiary(): Boolean = faladorEliteDiaryComplete diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt new file mode 100644 index 000000000..26b15dd64 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt @@ -0,0 +1,373 @@ +package org.rsmod.content.skills.crafting + +import dev.openrune.types.ItemServerType +import dev.openrune.types.StatType +import org.rsmod.api.table.Tuple2 +import org.rsmod.api.table.Tuple3 +import org.rsmod.api.table.crafting.CraftingFacilitiesRow +import org.rsmod.api.table.crafting.CraftingGoldRow +import org.rsmod.api.table.crafting.CraftingHandRow +import org.rsmod.api.table.crafting.CraftingSilverRow +import org.rsmod.content.skills.Material +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.content.skills.crafting.util.CraftingQuestReq +import org.rsmod.content.skills.crafting.util.CraftingVarbitReq +import org.rsmod.content.skills.crafting.util.craftingQuestReq +import org.rsmod.content.skills.crafting.util.craftingVarbitReq + +/** A single crafting recipe, normalised from a table row and resolved against its section. */ +data class CraftingProduct( + val section: CraftingSection, + val output: String, + val outputCount: Int = 1, + val inputs: List, + val level: Int, + /** Stored in tenths of a point so the int columns can carry fractional xp. CraftingWorker + * divides by [CraftingConstants.FINE_XP_DIVISOR] */ + val xp: Double, + val extraReqs: List = emptyList(), + val extraXp: List = emptyList(), + val triggers: List = emptyList(), + val ticks: List, + val anims: List = emptyList(), + val imcandoAnim: String? = null, + val locAnim: String? = null, + val sound: String? = null, + val spotanims: List = emptyList(), + val tools: List = emptyList(), + val consumesThread: Boolean = false, + val byproducts: List = emptyList(), + val failure: CraftingFailure? = null, + val startMessage: String? = null, + val successMessage: String? = null, + val confirmTitles: List = emptyList(), + val confirmWarning: String? = null, + val resultDialogue: String? = null, + val questReqs: List = emptyList(), + val varbitReqs: List = emptyList(), + val lockedMessage: String? = null, + val missingInputMessage: String? = null, + /** Shown when this recipe's output is used on the facility that made it. */ + val alreadyProcessedMessage: String? = null, + val requiresMaterialsToShow: Boolean = false, + val actionName: String, +) { + /** This craft's tick timing, with the last entry carrying the rest of a batch. */ + fun ticksAt(cycle: Int): Int = ticks[minOf(cycle, ticks.lastIndex)] + + /** This craft's animation, cycling back to the first once the list runs out. */ + fun animAt(cycle: Int): String? = anims.getOrNull(cycle % anims.size.coerceAtLeast(1)) + + fun spotanimAt(cycle: Int): String? = spotanims.getOrNull(cycle % spotanims.size.coerceAtLeast(1)) + + /** How many crafts the given inventory counts allow. */ + fun maxCraftable(counts: (String) -> Int): Int = inputs.minOfOrNull { counts(it.internal) / it.count } ?: 0 + + /** The first input the player is short on, or null when they have everything. */ + fun deficientInput(counts: (String) -> Int): Material? = inputs.firstOrNull { counts(it.internal) < it.count } +} + +/** + * A requirement in a skill other than Crafting. [stat] is the stat gameval ("stat.smithing"); + * [displayName] is what the player is shown ("Smithing"). + */ +data class CraftingStatReq(val stat: String, val displayName: String, val level: Int) + +/** + * Experience granted in a skill other than Crafting on a successful craft. In tenths of a point, + * like [CraftingProduct.xp]; CraftingWorker applies [CraftingConstants.FINE_XP_DIVISOR]. + */ +data class CraftingStatXp(val stat: String, val xp: Double) + +/** Failure odds expressed through the standard skilling success roll. */ +data class CraftingFailure( + /** Success numerators out of 256 at level 1 and at level 99. Above 256 succeeds before 99. */ + val low: Int, + val high: Int, + val item: String? = null, + val itemCount: Int = 1, + val xp: Double = 0.0, + val message: String? = null, + val sound: String? = null, +) + +/** The one place a table row becomes a [CraftingProduct], resolving it against its [section] for defaults. */ +fun craftingProduct( + section: CraftingSection, + output: ItemServerType, + outputCount: Int, + input: List, + inputAmount: List, + statReq: List>, + fineXp: Int, + anims: List = emptyList(), + ticks: List = emptyList(), + sound: String? = null, + locAnim: String? = null, + spotanims: List = emptyList(), + successLow: Int? = null, + successHigh: Int? = null, + failItem: ItemServerType? = null, + failItemCount: Int = 1, + failXp: Int? = null, + extraTools: List = emptyList(), + byproducts: List = emptyList(), + requiresMaterialsToShow: Boolean = false, + xpExtra: List = emptyList(), + triggers: List = emptyList(), + message: String? = null, + actionName: String? = null, + confirmTitles: List = emptyList(), + confirmWarning: String? = null, + resultDialogue: String? = null, + questReqs: List = emptyList(), + varbitReqs: List = emptyList(), + lockedMessage: String? = null, +): CraftingProduct { + val names = CraftingNames( + input = input.firstOrNull()?.name?.lowercase().orEmpty(), + output = output.name.lowercase(), + ) + val failure = if (successLow != null && successLow > 0) { + CraftingFailure( + low = successLow, + high = successHigh ?: successLow, + item = failItem?.internalName, + itemCount = failItemCount, + xp = (failXp ?: 0).toDouble(), + message = section.failureMessage(names), + sound = CraftingGamevals.optional(section.failureSound), + ) + } else { + null + } + val (craftingLevel, extraReqs) = statReq.splitCraftingReq() + return CraftingProduct( + section = section, + output = output.internalName, + outputCount = outputCount, + inputs = input.mapIndexed { i, obj -> Material(obj.internalName, inputAmount.getOrElse(i) { 1 }) }, + level = craftingLevel, + xp = fineXp.toDouble(), + extraReqs = extraReqs, + extraXp = xpExtra, + triggers = triggers.map { it.internalName }, + ticks = ticks.ifEmpty { listOf(section.ticks) }.map { it.coerceAtLeast(1) }, + anims = CraftingGamevals.filterResolvable(anims.ifEmpty { listOfNotNull(section.anim) }), + imcandoAnim = CraftingGamevals.optional(section.imcandoAnim), + locAnim = CraftingGamevals.optional(locAnim ?: section.locAnim), + sound = CraftingGamevals.optional(sound ?: section.sound), + spotanims = CraftingGamevals.filterResolvable(spotanims), + tools = section.tools + extraTools, + consumesThread = section.consumesThread, + byproducts = byproducts, + failure = failure, + startMessage = section.startMessage(names), + successMessage = + when { + message == null -> section.successMessage(names) + message.isBlank() -> null + else -> message.render(names) + }, + missingInputMessage = section.missingInputMessage(names), + alreadyProcessedMessage = section.alreadyProcessedMessage(names), + requiresMaterialsToShow = requiresMaterialsToShow, + actionName = actionName?.render(names) ?: section.actionName(names), + confirmTitles = confirmTitles, + confirmWarning = confirmWarning, + resultDialogue = resultDialogue, + questReqs = questReqs, + varbitReqs = varbitReqs, + lockedMessage = lockedMessage, + ).also(CraftingRecipes::register) +} + +/** Fills a row's message template, where `{input}` and `{output}` become the item names. */ +private fun String.render(names: CraftingNames): String = replace("{input}", names.input).replace("{output}", names.output) + +/** Separates a row's first output, which is the recipe's product, from any byproducts after it. */ +private fun splitOutputs( + output: List, + outputAmount: List, +): Triple> { + val main = output.first() + val mainCount = outputAmount.firstOrNull() ?: 1 + val byproducts = output.drop(1).mapIndexed { i, obj -> Material(obj.internalName, outputAmount.getOrElse(i + 1) { 1 }) } + return Triple(main, mainCount, byproducts) +} + +// A column no row fills generates an optional scalar, and one some row fills twice generates a +// list, so every multi capable column is read through an overload pair that accepts either one. +private fun objValues(value: ItemServerType?): List = listOfNotNull(value) + +private fun objValues(value: List): List = value + +private fun strValues(value: String?): List = listOfNotNull(value) + +private fun strValues(value: List): List = value + +private fun intValues(value: Int?): List = listOfNotNull(value) + +private fun intValues(value: List): List = value + +private fun questReqs(value: Tuple2?): List = + listOfNotNull(craftingQuestReq(value?.t0, value?.t1)) + +private fun questReqs(value: List>): List = + value.mapNotNull { craftingQuestReq(it.t0, it.t1) } + +private fun varbitReqs(value: Tuple3?): List = + listOfNotNull(craftingVarbitReq(value?.t0, value?.t1, value?.t2)) + +private fun varbitReqs(value: List>): List = + value.mapNotNull { craftingVarbitReq(it.t0, it.t1, it.t2) } + +private fun statXp(value: Tuple2?): List { + val stat = value?.t0 ?: return emptyList() + return listOf(CraftingStatXp(stat.internalName, (value.t1 ?: 0).toDouble())) +} + +private fun statXp(value: List>): List = + value.map { CraftingStatXp(it.t0.internalName, it.t1.toDouble()) } + +/** A `crafting_hand` row, covering needlework, gems, combines, birdhouses and the rest. */ +fun CraftingHandRow.toCraftingProduct(): CraftingProduct { + val (mainOutput, mainCount, extraOutputs) = splitOutputs(output, outputAmount) + return craftingProduct( + section = CraftingSection.byId(section), + output = mainOutput, + outputCount = mainCount, + input = input, + inputAmount = inputAmount, + statReq = statReq, + fineXp = xp, + anims = strValues(anim), + ticks = intValues(ticks), + sound = sound, + spotanims = strValues(spotanim), + successLow = successLow, + successHigh = successHigh, + failItem = failItem, + failXp = failXp, + extraTools = objValues(tool).map { it.internalName }, + xpExtra = statXp(xpExtra), + triggers = triggers, + message = message, + actionName = actionName, + confirmTitles = strValues(confirmTitle), + confirmWarning = confirmWarning, + resultDialogue = resultDialogue, + questReqs = questReqs(questReq), + varbitReqs = varbitReqs(unlockVarbit), + lockedMessage = lockedMessage, + byproducts = extraOutputs, + ) +} + +/** A `crafting_facilities` row, covering spinning, weaving, pottery and glass smelting. */ +fun CraftingFacilitiesRow.toCraftingProduct( + anim: String? = null, + requiresMaterialsToShow: Boolean = false, +): CraftingProduct { + val (mainOutput, mainCount, extraOutputs) = splitOutputs(output, outputAmount) + return craftingProduct( + section = CraftingSection.byId(section), + output = mainOutput, + outputCount = mainCount, + input = input, + inputAmount = inputAmount, + statReq = statReq, + fineXp = xp, + anims = strValues(this.anim).ifEmpty { listOfNotNull(anim) }, + ticks = intValues(ticks), + sound = sound, + spotanims = strValues(spotanim), + successLow = successLow, + successHigh = successHigh, + failItem = failItem, + failXp = failXp, + extraTools = objValues(tool).map { it.internalName }, + xpExtra = statXp(xpExtra), + message = message, + actionName = actionName, + confirmTitles = strValues(confirmTitle), + confirmWarning = confirmWarning, + resultDialogue = resultDialogue, + questReqs = questReqs(questReq), + varbitReqs = varbitReqs(unlockVarbit), + lockedMessage = lockedMessage, + byproducts = extraOutputs, + requiresMaterialsToShow = requiresMaterialsToShow, + ) +} + +/** A `crafting_silver` row, whose mould rides along as an extra tool. */ +fun CraftingSilverRow.toCraftingProduct(): CraftingProduct = + craftingProduct( + section = CraftingSection.byId(section), + output = output, + outputCount = outputAmount, + input = input, + inputAmount = inputAmount, + statReq = statReq, + fineXp = xp, + anims = strValues(anim), + ticks = intValues(ticks), + sound = sound, + spotanims = strValues(spotanim), + extraTools = objValues(tool).map { it.internalName }, + message = message, + actionName = actionName, + confirmTitles = strValues(confirmTitle), + confirmWarning = confirmWarning, + resultDialogue = resultDialogue, + questReqs = questReqs(questReq), + varbitReqs = varbitReqs(unlockVarbit), + lockedMessage = lockedMessage, + ) + +/** A `crafting_gold` row, whose mould rides along as an extra tool. */ +fun CraftingGoldRow.toCraftingProduct(): CraftingProduct = + craftingProduct( + section = CraftingSection.byId(section), + output = output, + outputCount = outputAmount, + input = input, + inputAmount = inputAmount, + statReq = statReq, + fineXp = xp, + anims = strValues(anim), + ticks = intValues(ticks), + sound = sound, + spotanims = strValues(spotanim), + extraTools = objValues(tool).map { it.internalName }, + message = message, + actionName = actionName, + confirmTitles = strValues(confirmTitle), + confirmWarning = confirmWarning, + resultDialogue = resultDialogue, + questReqs = questReqs(questReq), + varbitReqs = varbitReqs(unlockVarbit), + lockedMessage = lockedMessage, + ) + +/** Player facing name for a stat, falling back to the title cased gameval suffix. */ +private fun StatType.playerName(): String = + displayName.ifBlank { + internalName.substringAfterLast('.').replaceFirstChar(Char::uppercase) + } + +/** + * Pulls the Crafting entry out of a row's stat_req column. Its level becomes the recipe's + * level and every other skill becomes an extra requirement. + */ +private fun List>.splitCraftingReq(): Pair> { + val craftingReq = firstOrNull { req -> req.t0.isType(CraftingConstants.STAT_CRAFTING) } + val otherReqs = filter { req -> req !== craftingReq } + val extraReqs = otherReqs.map { req -> + CraftingStatReq(req.t0.internalName, req.t0.playerName(), req.t1) + } + val craftingLevel = craftingReq?.t1 ?: 1 + return craftingLevel to extraReqs +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt new file mode 100644 index 000000000..097d2d06a --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt @@ -0,0 +1,329 @@ +package org.rsmod.content.skills.crafting + +import org.rsmod.content.skills.SkillingActionType +import org.rsmod.content.skills.crafting.util.CraftingConstants + +/** Display names a section's message templates interpolate. */ +data class CraftingNames(val input: String, val output: String) + +/** How a section's recipes are started and produced. */ +enum class CraftingMode { + MENU, + INSTANT, + COMBINE, + SERVICE, +} + +/** The defaults a group of recipes share, covering pacing, cosmetics, tools and messages. */ +enum class CraftingSection( + val id: String, + val verb: String, + val actionType: SkillingActionType, + /** Default ticks per craft, which a recipe's ticks column overrides. */ + val ticks: Int, + /** Whether the first craft of a batch runs a tick short, as most crafting does. */ + val shortensFirstCraft: Boolean = true, + val mode: CraftingMode = CraftingMode.MENU, + val anim: String? = null, + val imcandoAnim: String? = null, + val locAnim: String? = null, + val sound: String? = null, + val failureSound: String? = null, + val tools: List = emptyList(), + val consumesThread: Boolean = false, + val ownsDefaultHandler: (input: String) -> Boolean = { false }, + val actionName: (CraftingNames) -> String, + val startMessage: (CraftingNames) -> String? = { null }, + val successMessage: (CraftingNames) -> String? = { null }, + val failureMessage: (CraftingNames) -> String? = { null }, + val emptyMenuMessage: () -> String? = { null }, + val missingInputMessage: (CraftingNames) -> String? = { null }, + + /** Message when product is used on the loc */ + val alreadyProcessedMessage: (CraftingNames) -> String? = { null }, +) { + SPINNING( + id = "Spinning", + verb = "spin", + actionType = SkillingActionType.SPIN, + ticks = 3, + shortensFirstCraft = false, + anim = CraftingConstants.ANIM_SPINNING, + locAnim = CraftingConstants.LOC_ANIM_SPINNING, + sound = CraftingConstants.SOUND_SPINNING, + actionName = { "spin ${it.output}" }, + successMessage = { "You spin the ${it.input} into ${it.output}." }, + emptyMenuMessage = { "You don't have anything suitable to spin at this spinning wheel." }, + alreadyProcessedMessage = { "You have already spun this ${it.input}." }, + ), + + WEAVING( + id = "Weaving", + verb = "weave", + actionType = SkillingActionType.WEAVE, + ticks = 3, + shortensFirstCraft = false, + anim = CraftingConstants.ANIM_WEAVING, + locAnim = CraftingConstants.LOC_ANIM_WEAVING, + sound = CraftingConstants.SOUND_WEAVING, + actionName = { "weave ${it.output}" }, + successMessage = { null }, + emptyMenuMessage = { + "You either don't have the required items or don't have enough of them to weave " + + "anything at this loom." + }, + ), + + POTTERY_SHAPING( + id = "PotteryShaping", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 3, + anim = CraftingConstants.ANIM_POTTERY_WHEEL, + locAnim = CraftingConstants.LOC_ANIM_POTTERY_WHEEL, + sound = CraftingConstants.SOUND_POTTERY_WHEEL, + actionName = { "make ${it.output}" }, + successMessage = { "You make the clay into ${it.output.removePrefix("unfired ").withArticle()}." }, // Strips the "unfired " prefix off the output name + emptyMenuMessage = { "You don't have anything suitable to craft with." }, + ), + + POTTERY_FIRING( + id = "PotteryFiring", + verb = "fire", + actionType = SkillingActionType.FIRE, + ticks = 7, + anim = CraftingConstants.ANIM_POTTERY_OVEN, + sound = CraftingConstants.SOUND_FURNACE, + actionName = { "fire ${it.output}" }, + startMessage = { "You put the ${it.output} in the oven." }, + successMessage = { "You remove the ${it.output} from the oven." }, + failureMessage = { "The clay cracks in the oven and is ruined." }, + missingInputMessage = { "You don't have any ${it.output.plural()} which need firing." }, + ), + + GLASS_SMELTING( + id = "GlassSmelting", + verb = "smelt", + actionType = SkillingActionType.SMELT, + ticks = 3, + anim = CraftingConstants.ANIM_FURNACE, + sound = CraftingConstants.SOUND_FURNACE, + actionName = { "smelt molten glass" }, + successMessage = { "You heat the sand and soda ash in the furnace to make glass." }, + ), + + NEEDLEWORK( + id = "Needlework", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 3, + anim = CraftingConstants.ANIM_LEATHER_CRAFT, + sound = CraftingConstants.SOUND_LEATHER_CRAFT, + tools = listOf(CraftingConstants.NEEDLE), + consumesThread = true, + actionName = { "make ${it.output}" }, + successMessage = { "You make ${it.output}." }, + ), + + PHEASANT_COSTUME( + id = "PheasantCostume", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 3, + anim = CraftingConstants.ANIM_PHEASANT_COSTUME, + sound = CraftingConstants.SOUND_LEATHER_CRAFT, + tools = listOf(CraftingConstants.NEEDLE), + consumesThread = true, + actionName = { "make ${it.output}" }, + successMessage = { "You make ${it.output}." }, + ), + + SHIELDS( + id = "Shields", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 3, + sound = CraftingConstants.SOUND_LEATHER_CRAFT, + tools = listOf(CraftingConstants.HAMMER), + actionName = { "make a ${it.output}" }, + successMessage = { "You nail the pieces together to make a ${it.output}." }, + ), + + CARVING( + id = "Carving", + verb = "cut", + actionType = SkillingActionType.CUT, + ticks = 3, + anim = CraftingConstants.ANIM_SNAIL_SHELL_CUT, + sound = CraftingConstants.SOUND_GEM_CUTTING, + tools = listOf(CraftingConstants.CHISEL), + actionName = { "carve a ${it.output}" }, + successMessage = { "You carve the ${it.input} into a ${it.output}." }, + ), + + KNIFE( + id = "Knife", + verb = "cut", + actionType = SkillingActionType.CUT, + ticks = 1, + anim = CraftingConstants.ANIM_KNIFE_CUTTING, + tools = listOf(CraftingConstants.KNIFE), + actionName = { "make a ${it.output}" }, + successMessage = { null }, + ), + + GEMS( + id = "Gems", + verb = "cut", + actionType = SkillingActionType.CUT, + ticks = 2, + anim = CraftingConstants.ANIM_GEM_CUTTING, + sound = CraftingConstants.SOUND_GEM_CUTTING, + failureSound = CraftingConstants.SOUND_GEM_CRUSH, + tools = listOf(CraftingConstants.CHISEL), + actionName = { "cut ${it.output}s" }, + successMessage = { "You cut the ${it.output}." }, + failureMessage = { "You mis-hit the chisel and smash the ${it.output} to pieces!" }, + ), + + AMETHYST( + id = "Amethyst", + verb = "cut", + actionType = SkillingActionType.CUT, + ticks = 2, + anim = CraftingConstants.ANIM_AMETHYST_CUT, + sound = CraftingConstants.SOUND_GEM_CUTTING, + tools = listOf(CraftingConstants.CHISEL), + actionName = { "cut ${it.output}" }, + successMessage = { "You carefully cut the amethyst into ${it.output}." }, + ), + + /** One brick per click so we use [CraftingMode.INSTANT] here */ + LIMESTONE( + id = "Limestone", + verb = "cut", + actionType = SkillingActionType.CUT, + ticks = 1, + mode = CraftingMode.INSTANT, + anim = CraftingConstants.ANIM_LIMESTONE_CUT, + sound = CraftingConstants.SOUND_GEM_CUTTING, + tools = listOf(CraftingConstants.CHISEL), + actionName = { "cut the limestone" }, + successMessage = { "You cut the limestone into a brick." }, + failureMessage = { "You accidentally crush the limestone to bits." }, + ), + + GLASSBLOWING( + id = "Glassblowing", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 3, + anim = CraftingConstants.ANIM_GLASSBLOWING, + sound = CraftingConstants.SOUND_GLASSBLOWING, + tools = listOf(CraftingConstants.GLASSBLOWING_PIPE), + actionName = { "make ${it.output}" }, + successMessage = { "You make ${it.output.withArticle()}." }, + ), + + BATTLESTAVES( + id = "Battlestaves", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 2, + shortensFirstCraft = false, + anim = CraftingConstants.ANIM_BATTLESTAFF, + sound = CraftingConstants.SOUND_BATTLESTAFF_ATTACH, + actionName = { "make a ${it.output}" }, + successMessage = { "You attach the orb to the staff, making a ${it.output}." }, + ), + + AMULET_STRINGING( + id = "AmuletStringing", + verb = "string", + actionType = SkillingActionType.MAKE, + ticks = 2, + sound = CraftingConstants.SOUND_AMULET_STRINGING, + actionName = { "string ${it.output}" }, + successMessage = { "You string the amulet." }, + ), + + BIRDHOUSES( + id = "Birdhouses", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 3, + anim = CraftingConstants.ANIM_BIRDHOUSE, + imcandoAnim = CraftingConstants.ANIM_BIRDHOUSE_IMCANDO, + tools = listOf(CraftingConstants.HAMMER, CraftingConstants.CHISEL), + ownsDefaultHandler = { it != CraftingConstants.CLOCKWORK }, + actionName = { "make a ${it.output}" }, + ), + + SOFT_CLAY_MIXING( + id = "SoftClayMixing", + verb = "mix", + actionType = SkillingActionType.MAKE, + ticks = 2, + actionName = { "mix soft clay" }, + successMessage = { "You mix the clay and water.
You now have some soft workable clay." }, + ), + + COMBINING( + id = "Combining", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 1, + mode = CraftingMode.COMBINE, + actionName = { "make a ${it.output}" }, + successMessage = { "You attach the ${it.input}, making a ${it.output}." }, + ), + + /** Both the silver and gold tables. Their `category` column tells the two apart. */ + JEWELLERY( + id = "Jewellery", + verb = "make", + actionType = SkillingActionType.MAKE, + ticks = 3, + anim = CraftingConstants.ANIM_FURNACE, + sound = CraftingConstants.SOUND_FURNACE, + actionName = { "make ${it.output}" }, + ), + + SAND_PIT( + id = "SandPit", + verb = "fill", + actionType = SkillingActionType.MAKE, + ticks = 3, + anim = CraftingConstants.ANIM_SAND_PIT, + sound = CraftingConstants.SOUND_SAND_BUCKET, + actionName = { "fill a bucket with sand" }, + successMessage = { "You fill the bucket with sand." }, + ), + + TANNING( + id = "Tanning", + verb = "tan", + actionType = SkillingActionType.MAKE, + ticks = 0, + mode = CraftingMode.SERVICE, + actionName = { "tan ${it.output}" }, + ); + + companion object { + private val byId: Map = entries.associateBy { it.id } + + /** The section a table row names. */ + fun byId(id: String): CraftingSection = + requireNotNull(byId[id]) { "Unknown crafting section: '$id'" } + } +} + +/** Simple English pluralization for the item names used in section message templates. */ +internal fun String.plural(): String = when { + endsWith("s") || endsWith("x") || endsWith("z") || + endsWith("sh") || endsWith("ch") -> "${this}es" + else -> "${this}s" +} + +/** Prepends the indefinite article, giving an orb or a beer glass. */ +internal fun String.withArticle(): String = if (firstOrNull()?.lowercaseChar() in setOf('a', 'e', 'i', 'o', 'u')) "an $this" else "a $this" diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt new file mode 100644 index 000000000..a6cbca1a9 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt @@ -0,0 +1,491 @@ +package org.rsmod.content.skills.crafting + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.attr.AttributeKey +import org.rsmod.api.player.output.ChatType +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.script.onPlayerQueueWithArgs +import org.rsmod.content.skills.SkillMultiConfig +import org.rsmod.content.skills.SkillMultiEntry +import org.rsmod.content.skills.openSkillMulti +import org.rsmod.content.skills.crafting.util.CraftingConfig +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.content.skills.crafting.util.meetsUnlocks +import org.rsmod.api.player.stat.craftingLvl +import org.rsmod.content.skills.crafting.util.hasCraftingTool +import org.rsmod.content.skills.crafting.util.holdsCostumeNeedle +import org.rsmod.content.skills.crafting.util.holdsImcandoHammer +import org.rsmod.game.loc.BoundLocInfo +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext +import skillSuccess + +/** Remaining uses on the player's current spool of thread, where one spool covers five crafts. */ +private val THREAD_USES_ATTR = AttributeKey(persistenceKey = "crafting_thread_uses") + +private val CRAFT_ANIM_ATTR = AttributeKey() + +private val CRAFT_ANIM_END_ATTR = AttributeKey() + +/** Uses left on the current spool. */ +private fun ProtectedAccess.threadUses(): Int = player.attr.getOrDefault(THREAD_USES_ATTR, 0) + +private fun ProtectedAccess.setThreadUses(value: Int) { + player.attr[THREAD_USES_ATTR] = value +} + +/** The shared crafting engine that every section funnels through. */ +class CraftingWorkerScript @Inject constructor( + private val worldRepo: WorldRepository, +) : PluginScript() { + override fun ScriptContext.startup() { + CraftingRuntime.worldRepo = worldRepo + onPlayerQueueWithArgs(CraftingConstants.QUEUE_CRAFTING_MAKE) { + processCraftingTask(it.args) + } + } + + /** Dispatches a queued task to whichever half of the cycle it represents. */ + private suspend fun ProtectedAccess.processCraftingTask(task: CraftingTask) { + when (task.phase) { + CraftingPhase.BEGIN -> processBegin(task) + CraftingPhase.END -> processEnd(task) + } + } + + /** BEGIN phase, used only by sections carrying a start message such as pottery firing. */ + private suspend fun ProtectedAccess.processBegin(task: CraftingTask) { + val product = task.product + if (!canCraft(product, verbose = false)) { + resetAnim() + return + } + beginCycle(product, task.facility, task.completed) + weakQueue( + CraftingConstants.QUEUE_CRAFTING_MAKE, + (product.ticksAt(task.completed) - 1) + QUEUE_HANDLER_COMPENSATION, + task.copy(phase = CraftingPhase.END), + ) + } + + /** END phase, which consumes inputs, rolls failure, adds the output and sends the success message. */ + private suspend fun ProtectedAccess.processEnd(task: CraftingTask) { + val product = task.product + if (!craftOnce(product)) { + resetAnim() + return + } + val completed = task.completed + 1 + if (completed >= task.amount) { + return + } + if (product.startMessage != null) { + weakQueue( + CraftingConstants.QUEUE_CRAFTING_MAKE, + // Sections with a start message need it a tick after this cycle's end message, so + // the next cycle gets its own BEGIN instead of folding into the END below. + 1 + QUEUE_HANDLER_COMPENSATION, + task.copy(completed = completed, phase = CraftingPhase.BEGIN), + ) + } else { + beginCycle(product, task.facility, completed) + weakQueue( + CraftingConstants.QUEUE_CRAFTING_MAKE, + product.ticksAt(completed) + QUEUE_HANDLER_COMPENSATION, + task.copy(completed = completed, phase = CraftingPhase.END), + ) + } + } +} + +/** Queues scheduled from inside a queue handler lose a tick to the same cycle's decrement. */ +private const val QUEUE_HANDLER_COMPENSATION = 1 + +private const val CONFIRM_CRAFT_DELAY = 4 + +/** Which half of a craft cycle a queued task represents. */ +enum class CraftingPhase { + BEGIN, + END, +} + +/** Runtime references the pipeline reaches for during a craft but does not own itself. */ +internal object CraftingRuntime { + lateinit var worldRepo: WorldRepository +} + +/** Everything that fires at the start of a craft cycle, from animations to the start message. */ +private fun ProtectedAccess.beginCycle( + product: CraftingProduct, + facility: BoundLocInfo?, + cycle: Int = 0, +) { + // The client drops an animation already playing, so a sound fired every craft would outpace it. + val restarted = startCraftAnim(product, cycle) + if (restarted) { + product.sound?.let { soundSynth(it) } + } + product.spotanimAt(cycle)?.let { spotanim(it) } + if (product.locAnim != null && facility != null) { + locAnim(CraftingRuntime.worldRepo, facility, product.locAnim) + } + product.startMessage?.let { mes(it, ChatType.Spam) } +} + +/** + * Duration of the sequence gameval [seq] in server ticks, or `0` when the seq carries no duration + * data. `tickDuration` is game cycles; `totalDelay` is client frames and must not be used here. + */ +private fun seqTicks(seq: String): Int = + ServerCacheManager.getAnim(seq.asRSCM(RSCMType.SEQ))?.tickDuration ?: 0 + +/** Plays this cycle's animation and reports whether it restarted rather than overlapping. */ +private fun ProtectedAccess.startCraftAnim(product: CraftingProduct, cycle: Int): Boolean { + val seq = craftAnim(product, cycle) ?: return true + val now = player.currentMapClock + val playing = player.attr[CRAFT_ANIM_ATTR] == seq && now < player.attr.getOrDefault(CRAFT_ANIM_END_ATTR, 0) + anim(seq) + if (!playing) { + player.attr[CRAFT_ANIM_ATTR] = seq + player.attr[CRAFT_ANIM_END_ATTR] = now + seqTicks(seq).coerceAtLeast(1) + } + return !playing +} + +/** A queued crafting job, where [facility] is set only for facility based sections. */ +data class CraftingTask( + val product: CraftingProduct, + val amount: Int, + val completed: Int, + val facility: BoundLocInfo? = null, + val phase: CraftingPhase = CraftingPhase.END, +) + +/** Validates that [product] can currently be crafted, messaging the reason when [verbose]. */ +suspend fun ProtectedAccess.canCraft(product: CraftingProduct, verbose: Boolean): Boolean { + if (player.craftingLvl < product.level) { + if (verbose) { + mesbox("You need a Crafting level of at least ${product.level} to ${product.actionName}.") + } + return false + } + + for (req in product.extraReqs) { + if (stat(req.stat) < req.level) { + if (verbose) { + mesbox( + "You need a ${req.displayName} level of at least ${req.level} " + + "to ${product.actionName}.", + ) + } + return false + } + } + + for (tool in product.tools) { + if (!hasCraftingTool(tool)) { + if (verbose) { + mes("You don't have the required tool to do that.", ChatType.Spam) + } + return false + } + } + + if (product.consumesThread && !holdsCostumeNeedle() && !inv.contains(CraftingConstants.THREAD)) { + if (verbose) { + mes("You need some thread to make that.", ChatType.Spam) + } + return false + } + + val deficient = product.deficientInput { inv.count(it) } + if (deficient != null) { + if (verbose) { + val dialogue = product.missingInputMessage + if (dialogue != null) { + mesbox(dialogue) + } else { + mes("You don't have enough ${itemName(deficient.internal)} for that!", ChatType.Spam) + } + } + return false + } + + return true +} + +/** Performs a single craft of [product], consuming inputs, rolling failure and granting xp. */ +suspend fun ProtectedAccess.craftOnce(product: CraftingProduct): Boolean { + // Consume inputs, rolling back on partial failure. + val removed = mutableListOf>() + for (material in product.inputs) { + if (invDel(inv, material.internal, material.count).success) { + removed += material.internal to material.count + } else { + removed.forEach { (obj, count) -> invAdd(inv, obj, count) } + return false + } + } + + val failure = product.failure + + if (failure != null && !skillSuccess(failure.low, failure.high, player.craftingLvl)) { + failure.sound?.let { soundSynth(it) } + failure.item?.let { invAdd(inv, it, failure.itemCount) } + advanceCraftingXp(CraftingConstants.STAT_CRAFTING, failure.xp) + failure.message?.let { mes(it, ChatType.Spam) } + return true + } + + if (invAdd(inv, product.output, product.outputCount).failure) { + removed.forEach { (obj, count) -> invAdd(inv, obj, count) } + mes("You don't have enough inventory space to do that.", ChatType.Spam) + return false + } + + product.byproducts.forEach { invAdd(inv, it.internal, it.count) } + + if (product.consumesThread && !holdsCostumeNeedle()) { + consumeThreadCharge() + } + + advanceCraftingXp(CraftingConstants.STAT_CRAFTING, product.xp) + product.extraXp.forEach { advanceCraftingXp(it.stat, it.xp) } + val resultDialogue = product.resultDialogue + if (resultDialogue != null) { + objbox(product.output, resultDialogue) + } else { + product.successMessage?.let { mes(it, ChatType.Spam) } + } + return true +} + +/** Grants pipeline experience, converting out of the tenths that every xp value is stored in. */ +private fun ProtectedAccess.advanceCraftingXp(stat: String, fineXp: Double) { + if (fineXp <= 0.0) return + statAdvance(stat, fineXp / CraftingConstants.FINE_XP_DIVISOR) +} + +/** One spool of thread lasts [CraftingConstants.THREAD_USES_PER_SPOOL] crafts. */ +private fun ProtectedAccess.consumeThreadCharge() { + // A value of 0 means no spool has been started, so treat it as a fresh one. + val uses = threadUses().takeIf { it > 0 } ?: CraftingConstants.THREAD_USES_PER_SPOOL + if (uses <= 1) { + if (invDel(inv, CraftingConstants.THREAD, 1).success) { + mes("You use up one of your reels of thread.", ChatType.Spam) + } + setThreadUses(CraftingConstants.THREAD_USES_PER_SPOOL) + } else { + setThreadUses(uses - 1) + } +} + +/** Human-readable item name for messages, falling back to the raw gameval when unresolved. */ +private fun itemName(internal: String): String { + val id = CraftingGamevals.objOrNull(internal) ?: return internal + return ServerCacheManager.getItem(id)?.name?.lowercase() ?: internal +} + +/** Materials and tools check without level gating, used to decide what a prompt should show. */ +fun ProtectedAccess.hasCraftingMaterials(product: CraftingProduct): Boolean { + val hasTools = product.tools.all { hasCraftingTool(it) } + val hasThread = !product.consumesThread || holdsCostumeNeedle() || inv.contains(CraftingConstants.THREAD) + val hasInputs = product.inputs.all { inv.count(it.internal) >= it.count } + return hasTools && hasThread && hasInputs +} + +/** + * Starts the production loop for [amount] of [product], optionally animating [facility]. Pacing + * is the product's resolved [CraftingProduct.ticksAt] value for that craft. + * + * Cycle 1 begins inline on this same tick (anim/sound/start message fire immediately), and END + * is queued for T + ticks, unless [CraftingSection.shortensFirstCraft] is off. + * Subsequent cycles run through the queue handler. + */ +suspend fun ProtectedAccess.startCrafting( + product: CraftingProduct, + amount: Int, + facility: BoundLocInfo? = null, +) { + val capped = minOf(amount, product.maxCraftable { inv.count(it) }) + if (capped <= 0) { + return + } + val cycleTicks = product.ticksAt(0) + if (cycleTicks <= 0) { + beginCycle(product, facility) + repeat(capped) { + if (!craftOnce(product)) { + return + } + } + return + } + beginCycle(product, facility) + + // Cycle 1 spans ticks-1 rather than ticks, so the first craft lands a tick sooner than the + // steady-state product-to-product spacing. Sections that clear `shortensFirstCraft` keep the + // full cycle, because an effect that runs the whole cycle has no room to play out in a short + // one and is cut off when the next cycle restarts it. + val firstCycleTicks: Int + if (product.section.shortensFirstCraft) { + firstCycleTicks = maxOf(1, cycleTicks - 1) + } else { + firstCycleTicks = cycleTicks + } + + // Menu-resume paths run in PlayerInputProcess, before PlayerMainProcess.processQueues, so a + // queue added there loses a tick to that same cycle's decrement, while direct op-handler paths + // run after it and lose nothing. processedMapClock only matches currentMapClock once we are + // inside PlayerMainProcess, which tells the two apart. + val queuedBeforeProcessing = player.currentMapClock != player.processedMapClock + val phaseCompensation: Int + if (queuedBeforeProcessing) { + phaseCompensation = 1 + } else { + phaseCompensation = 0 + } + + weakQueue( + CraftingConstants.QUEUE_CRAFTING_MAKE, + firstCycleTicks + phaseCompensation, + CraftingTask( + product = product, + amount = capped, + completed = 0, + facility = facility, + phase = CraftingPhase.END, + ), + ) +} + +/** This craft's animation, preferring the imcando variant while an imcando hammer is held. */ +private fun ProtectedAccess.craftAnim(product: CraftingProduct, cycle: Int): String? = + product.imcandoAnim?.takeIf { holdsImcandoHammer() } ?: product.animAt(cycle) + +/** Performs exactly one craft of [product] now, with no menu, queue or tick pacing. */ +suspend fun ProtectedAccess.craftInstantly(product: CraftingProduct) { + if (!canCraft(product, verbose = true)) { + return + } + if (!confirmCraft(product)) { + return + } + beginCycle(product, facility = null) + if (product.confirmTitles.isNotEmpty()) { + delayForCraftAnims(product) + } + craftOnce(product) +} + +/** Holds a confirmed craft for as long as its animations take, so the item lands when they end. */ +private suspend fun ProtectedAccess.delayForCraftAnims(product: CraftingProduct) { + val anims = product.anims + if (anims.isEmpty()) { + delay(CONFIRM_CRAFT_DELAY) + return + } + delay(seqTicks(anims.first()).coerceAtLeast(1)) + for (cycle in 1 until anims.size) { + beginCycle(product, null, cycle) + delay(seqTicks(anims[cycle]).coerceAtLeast(1)) + } +} + +/** Runs a recipe's confirmation prompts, stopping as soon as one is declined. */ +suspend fun ProtectedAccess.confirmCraft(product: CraftingProduct): Boolean { + if (product.confirmTitles.isEmpty()) { + return true + } + product.confirmWarning?.let { mesbox(it) } + product.confirmTitles.forEachIndexed { index, title -> + val confirmed = + if (index == 0) { + choice2("Yes.", true, "No.", false, title = title) + } else { + choice2("No.", false, "Yes.", true, title = title) + } + if (!confirmed) { + return false + } + } + return true +} + +/** + * Presents the "what would you like to make / how many" prompt for [products] and starts + * production with the player's selection. + * The prompt's verb and action type come from [section]. + */ +suspend fun ProtectedAccess.selectCraftingProduct( + section: CraftingSection, + products: List, + facility: BoundLocInfo? = null, +) { + val unlocked = products.filter { player.meetsUnlocks(it) } + val shown = unlocked.filter { !it.requiresMaterialsToShow || hasCraftingMaterials(it) } + val emptyMessage = section.emptyMenuMessage() + if (emptyMessage != null && products.none { hasCraftingMaterials(it) }) { + mesbox(emptyMessage) + return + } + if (shown.isEmpty()) { + return + } + + val productsByOutput = shown.associateBy { it.output } + + if (shown.size == 1 && CraftingConfig.SKIP_SINGLE_RECIPE_PROMPT) { + beginCraft(shown.single(), Int.MAX_VALUE, facility) + return + } + + openSkillMultiForProducts(section, shown) { selected, amount -> + productsByOutput[selected]?.let { beginCraft(it, amount, facility) } + } +} + +/** Validates the chosen [product] and either starts crafting or messages why it cannot. */ +suspend fun ProtectedAccess.beginCraft( + product: CraftingProduct, + amount: Int, + facility: BoundLocInfo? = null, +) { + if (!canCraft(product, verbose = true)) { + return + } + if (!confirmCraft(product)) { + return + } + startCrafting(product, amount, facility) +} + +/** Opens the multiskill prompt, showing at least one of each recipe so none are filtered out. */ +private suspend fun ProtectedAccess.openSkillMultiForProducts( + section: CraftingSection, + shown: List, + onSelect: suspend (output: String, amount: Int) -> Unit, +) { + val entries = shown.map { + product -> SkillMultiEntry(product.output, product.inputs) + } + openSkillMulti( + SkillMultiConfig( + verb = section.verb, + actionType = section.actionType, + entries = entries, + maxCountProvider = { inventory, entry -> + val product = shown.firstOrNull { it.output == entry.internal } + product?.maxCraftable { inventory.count(it) }?.coerceAtLeast(1) ?: 1 + }, + ), + ) + { + selection -> onSelect(selection.entry.internal, selection.amount) + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt new file mode 100644 index 000000000..bc3bd9e7e --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt @@ -0,0 +1,174 @@ +package org.rsmod.content.skills.crafting.interfaces + +import dev.openrune.definition.type.widget.IfEvent +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.vars.intVarBit +import org.rsmod.api.script.onIfModalButton +import org.rsmod.api.table.crafting.CraftingGoldRow +import org.rsmod.content.skills.crafting.CraftingProduct +import org.rsmod.content.skills.crafting.beginCraft +import org.rsmod.content.skills.crafting.hasCraftingMaterials +import org.rsmod.content.skills.crafting.toCraftingProduct +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.content.skills.crafting.util.meetsUnlocks +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** The gold crafting interface, opened by a gold bar on a furnace or the furnace's smelt op. */ +class GoldCraftingInterfaceScript : PluginScript() { + override fun ScriptContext.startup() { + check(goldProducts.isNotEmpty()) { "No gold crafting recipes resolved" } + + for (slot in goldSlots) { + onIfModalButton(slot.component) { craftGoldSlot(slot) } + } + for (quantity in GOLD_COLUMN.buttons) { + onIfModalButton(GOLD_COLUMN.component(quantity.button)) { + selectMakeQuantity(GOLD_COLUMN, quantity) + } + } + if (CraftingGamevals.exists(GOLD_COLUMN.someButton)) { + onIfModalButton(GOLD_COLUMN.someButton) {} + } + } +} + +/** Slot the highlight box is drawn behind. See [GoldSlot.lastType]. */ +private var Player.goldLastType by intVarBit(VARBIT_GOLD_LASTTYPE) + +fun ProtectedAccess.openGoldCrafting() { + resetMakeQuantity() + ifOpenMainModal(INTERFACE_GOLD_CRAFTING) + for (slot in goldSlots) { + ifSetEvents(slot.component, -1..-1, IfEvent.Op1) + if (CraftingGamevals.exists(slot.component)) { + ifSetHide(slot.component, hide = !slotUnlocked(slot)) + } + } +} + +/** Whether any recipe this slot can make passes its gates. */ +private fun ProtectedAccess.slotUnlocked(slot: GoldSlot): Boolean { + val products = slot.outputs.mapNotNull { goldProducts[it] } + return products.isEmpty() || products.any { player.meetsUnlocks(it) } +} + +fun ProtectedAccess.hasGoldCraftingBars(): Boolean = inv.contains(CraftingConstants.GOLD_BAR) + +/** The recipe a slot would make now, or null when the player can make none of them. */ +private fun ProtectedAccess.craftableProduct(slot: GoldSlot): CraftingProduct? = + slot.outputs.firstNotNullOfOrNull { output -> + goldProducts[output]?.takeIf { hasCraftingMaterials(it) } + } + +/** [beginCraft] re-validates level, mould and materials, and caps the amount to the inventory. */ +private suspend fun ProtectedAccess.craftGoldSlot(slot: GoldSlot) { + if (!slotUnlocked(slot)) { + return + } + val product = craftableProduct(slot) ?: return + player.goldLastType = slot.lastType + ifClose() + beginCraft(product, makeQuantity()) +} + +/** Gold's quantity column is stacked down the left of the interface. */ +private val GOLD_COLUMN = + MakeQuantityColumn( + component = ::goldComponent, + countedObj = CraftingConstants.GOLD_BAR, + stepX = 0, + stepY = 40, + ) + +/** A mould section, and the value its first slot is numbered from. */ +internal enum class GoldSection(val lastTypeBase: Int) { + Rings(1), + Necklaces(10), + Amulets(18), + Bracelets(26), +} + +internal data class GoldSlot( + val section: GoldSection, + val component: String, + val outputs: List, + val lastType: Int, +) + +/** Each slot's component and the objs its `dbtable.crafting_gold` row makes there. */ +private val GOLD_SLOTS: List = withLastTypes( + listOf( + rawSlot(GoldSection.Rings, "gold_ring", "obj.gold_ring"), + rawSlot(GoldSection.Rings, "sapphire_ring", "obj.sapphire_ring"), + rawSlot(GoldSection.Rings, "emerald_ring", "obj.emerald_ring"), + rawSlot(GoldSection.Rings, "ruby_ring", "obj.ruby_ring"), + rawSlot(GoldSection.Rings, "diamond_ring", "obj.diamond_ring"), + rawSlot(GoldSection.Rings, "dragon_ring", "obj.dragonstone_ring"), + rawSlot(GoldSection.Rings, "onyx_ring", "obj.onyx_ring"), + rawSlot(GoldSection.Rings, "zenyte_ring", "obj.zenyte_ring"), + // Drawn eighth but numbered last, and the slayer and eternal rings share the one slot. + rawSlot(GoldSection.Rings, "slayer_ring", "obj.slayer_ring_eternal", "obj.slayer_ring_8"), + + rawSlot(GoldSection.Necklaces, "gold_necklace", "obj.gold_necklace"), + rawSlot(GoldSection.Necklaces, "sapphire_necklace", "obj.sapphire_necklace"), + rawSlot(GoldSection.Necklaces, "emerald_necklace", "obj.emerald_necklace"), + rawSlot(GoldSection.Necklaces, "ruby_necklace", "obj.ruby_necklace"), + rawSlot(GoldSection.Necklaces, "diamond_necklace", "obj.diamond_necklace"), + rawSlot(GoldSection.Necklaces, "dragon_necklace", "obj.dragonstone_necklace"), + rawSlot(GoldSection.Necklaces, "onyx_necklace", "obj.onyx_necklace"), + rawSlot(GoldSection.Necklaces, "zenyte_necklace", "obj.zenyte_necklace"), + + rawSlot(GoldSection.Amulets, "gold_amulet", "obj.unstrung_gold_amulet"), + rawSlot(GoldSection.Amulets, "sapphire_amulet", "obj.unstrung_sapphire_amulet"), + rawSlot(GoldSection.Amulets, "emerald_amulet", "obj.unstrung_emerald_amulet"), + rawSlot(GoldSection.Amulets, "ruby_amulet", "obj.unstrung_ruby_amulet"), + rawSlot(GoldSection.Amulets, "diamond_amulet", "obj.unstrung_diamond_amulet"), + rawSlot(GoldSection.Amulets, "dragon_amulet", "obj.unstrung_dragonstone_amulet"), + rawSlot(GoldSection.Amulets, "onyx_amulet", "obj.unstrung_onyx_amulet"), + rawSlot(GoldSection.Amulets, "zenyte_amulet", "obj.unstrung_zenyte_amulet"), + + rawSlot(GoldSection.Bracelets, "gold_bracelet", "obj.jewl_gold_bracelet"), + rawSlot(GoldSection.Bracelets, "sapphire_bracelet", "obj.jewl_sapphire_bracelet"), + rawSlot(GoldSection.Bracelets, "emerald_bracelet", "obj.jewl_emerald_bracelet"), + rawSlot(GoldSection.Bracelets, "ruby_bracelet", "obj.jewl_ruby_bracelet"), + rawSlot(GoldSection.Bracelets, "diamond_bracelet", "obj.jewl_diamond_bracelet"), + rawSlot(GoldSection.Bracelets, "dragon_bracelet", "obj.jewl_dragonstone_bracelet"), + rawSlot(GoldSection.Bracelets, "onyx_bracelet", "obj.jewl_onyx_bracelet"), + rawSlot(GoldSection.Bracelets, "zenyte_bracelet", "obj.zenyte_bracelet"), + ), +) + +/** A slot before its lastType is assigned. */ +private fun rawSlot(section: GoldSection, component: String, vararg outputs: String): GoldSlot = + GoldSlot(section, goldComponent(component), outputs.toList(), lastType = 0) + +/** Numbers each slot within its section, which is what the highlight box reads. */ +private fun withLastTypes(slots: List): List { + val next = mutableMapOf() + return slots.map { slot -> + val index = next.getOrDefault(slot.section, 0) + next[slot.section] = index + 1 + slot.copy(lastType = slot.section.lastTypeBase + index) + } +} + +/** The interface's slots, with unresolvable components dropped so startup cannot abort. */ +internal val goldSlots: List by lazy { + GOLD_SLOTS.filter { CraftingGamevals.exists(it.component) } +} + +/** Every gold recipe keyed by the obj it makes, which is how a slot finds its recipe. */ +internal val goldProducts: Map by lazy { + CraftingGoldRow.all().associate { it.output.internalName to it.toCraftingProduct() } +} + +private const val INTERFACE_GOLD_CRAFTING = "interface.crafting_gold" + +/** Last-selected-item varbit, which is what the highlight box reads. */ +private const val VARBIT_GOLD_LASTTYPE = "varbit.crafting_gold_item_lasttype" + +/** A component of the gold crafting interface, by its name in the interface JSON. */ +private fun goldComponent(name: String): String = "component.crafting_gold:$name" diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/MakeQuantity.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/MakeQuantity.kt new file mode 100644 index 000000000..7bf54e3ef --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/MakeQuantity.kt @@ -0,0 +1,84 @@ +package org.rsmod.content.skills.crafting.interfaces + +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.vars.intVarp +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.entity.Player + +/** The `skillmain` quantity column, shared by the gold and silver crafting interfaces. */ +internal class MakeQuantityColumn( + val component: (String) -> String, + /** The bar `skillmain` counts to decide which buttons exist. */ + val countedObj: String, + /** Offsets the buttons lay out with, since gold stacks them and silver puts them in a row. */ + val stepX: Int, + val stepY: Int, +) { + val buttons: List by lazy { + MakeQuantity.entries.filter { CraftingGamevals.exists(component(it.button)) } + } + + val someButton: String by lazy { component("make_some") } +} + +/** A fixed quantity button and the amount it selects, null meaning X which asks first. */ +internal enum class MakeQuantity(val button: String, val amount: Int?) { + One("make_1", 1), + Five("make_5", 5), + Ten("make_10", 10), + X("make_x", null), + All("make_all", MAX_QUANTITY), +} + +private var Player.makeQuantity by intVarp(CraftingConstants.VARP_MAKEX_CRAFTING) + +/** Reset before opening so the client's on load draw picks up the right button. */ +internal fun ProtectedAccess.resetMakeQuantity() { + player.makeQuantity = 1 +} + +internal fun ProtectedAccess.makeQuantity(): Int = player.makeQuantity.coerceAtLeast(1) + +/** Mirrors the clicked button into the varp, prompting first for the X button. */ +internal suspend fun ProtectedAccess.selectMakeQuantity( + column: MakeQuantityColumn, + quantity: MakeQuantity, +) { + val amount = quantity.amount + if (amount == null) { + promptMakeQuantity(column) + } else { + player.makeQuantity = amount + } +} + +/** Asks for an amount and lets `skillmain_setup` work out which button that lands on. */ +private suspend fun ProtectedAccess.promptMakeQuantity(column: MakeQuantityColumn) { + player.makeQuantity = 0 + val max = minOf(inv.count(column.countedObj), MAX_QUANTITY) + val input = countDialog("Enter amount: (1-$max)") + player.makeQuantity = input.coerceAtLeast(1) + refreshMakeQuantity(column) +} + +/** Rebuilds the quantity column by re-running the script the interface itself calls on load. */ +private fun ProtectedAccess.refreshMakeQuantity(column: MakeQuantityColumn) { + runClientScript( + SKILLMAIN_INIT, + column.component("makex").asRSCM(RSCMType.COMPONENT), + column.stepX, + column.stepY, + column.countedObj.asRSCM(RSCMType.OBJ), + *MakeQuantity.entries.map { column.component(it.button).asRSCM(RSCMType.COMPONENT) }.toTypedArray(), + column.someButton.asRSCM(RSCMType.COMPONENT), + ) +} + +/** `clientscript,skillmain_init`, the make quantity column shared across skill interfaces. */ +private const val SKILLMAIN_INIT = 2926 + +/** The most `skillmain_init` will select, being a full inventory. */ +internal const val MAX_QUANTITY = 28 diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/SilverCraftingInterface.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/SilverCraftingInterface.kt new file mode 100644 index 000000000..6b54ddb30 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/SilverCraftingInterface.kt @@ -0,0 +1,119 @@ +package org.rsmod.content.skills.crafting.interfaces + +import dev.openrune.definition.type.widget.IfEvent +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.vars.intVarBit +import org.rsmod.api.script.onIfModalButton +import org.rsmod.api.table.crafting.CraftingSilverRow +import org.rsmod.content.skills.crafting.CraftingProduct +import org.rsmod.content.skills.crafting.beginCraft +import org.rsmod.content.skills.crafting.toCraftingProduct +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class SilverCraftingInterfaceScript : PluginScript() { + override fun ScriptContext.startup() { + check(silverProducts.isNotEmpty()) { "No silver crafting recipes resolved" } + + for (slot in silverSlots) { + onIfModalButton(slot.component) { craftSilverSlot(slot) } + } + for (quantity in SILVER_COLUMN.buttons) { + onIfModalButton(SILVER_COLUMN.component(quantity.button)) { + selectMakeQuantity(SILVER_COLUMN, quantity) + } + } + if (CraftingGamevals.exists(SILVER_COLUMN.someButton)) { + onIfModalButton(SILVER_COLUMN.someButton) {} + } + } +} + +private var Player.silverLastType by intVarBit(VARBIT_SILVER_LASTTYPE) + +/** Opens the silver interface. */ +fun ProtectedAccess.openSilverCrafting() { + resetMakeQuantity() + ifOpenMainModal(INTERFACE_SILVER_CRAFTING) + for (slot in silverSlots) { + ifSetEvents(slot.component, -1..-1, IfEvent.Op1) + } +} + +/** Bars alone are enough to open, since the interface tells the player which moulds it needs. */ +fun ProtectedAccess.hasSilverCraftingBars(): Boolean = inv.contains(CraftingConstants.SILVER_BAR) + +/** [beginCraft] validates level, mould and materials, and caps the amount to the inventory. */ +private suspend fun ProtectedAccess.craftSilverSlot(slot: SilverSlot) { + val product = silverProducts[slot.output] ?: return + player.silverLastType = slot.lastType + ifClose() + beginCraft(product, makeQuantity()) +} + +private val SILVER_COLUMN = + MakeQuantityColumn( + component = ::silverComponent, + countedObj = CraftingConstants.SILVER_BAR, + stepX = 50, + stepY = 0, + ) + +internal data class SilverSlot( + val component: String, + val output: String, + val lastType: Int, +) + +private val SILVER_SLOTS: List = + withSilverLastTypes( + listOf( + "opal_ring" to "obj.opal_ring", + "jade_ring" to "obj.jade_ring", + "topaz_ring" to "obj.topaz_ring", + "opal_necklace" to "obj.opal_necklace", + "jade_necklace" to "obj.jade_necklace", + "topaz_necklace" to "obj.topaz_necklace", + "opal_amulet" to "obj.unstrung_opal_amulet", + "jade_amulet" to "obj.unstrung_jade_amulet", + "topaz_amulet" to "obj.unstrung_topaz_amulet", + "opal_bracelet" to "obj.opal_bracelet", + "jade_bracelet" to "obj.jade_bracelet", + "topaz_bracelet" to "obj.topaz_bracelet", + "holy_symbol" to "obj.nostringstar", + "unholy_symbol" to "obj.nostringsnake", + "sickle" to "obj.silver_sickle", + "lightning_rod" to "obj.fenk_conductor", + "crossbow_bolt" to "obj.xbows_crossbow_bolts_silver_unfeathered", + "tiara" to "obj.tiara", + "ivandis" to "obj.burgh_rod_command1", + "agrith_sigil" to "obj.agrith_sigil", + ) + ) + +/** Numbers each slot, which is what the highlight box reads. */ +private fun withSilverLastTypes(slots: List>): List = + slots.mapIndexed { index, (component, output) -> + SilverSlot(silverComponent(component), output, lastType = index + 1) + } + +/** The interface's slots, with unresolvable components dropped. */ +internal val silverSlots: List by lazy { + SILVER_SLOTS.filter { CraftingGamevals.exists(it.component) } +} + +/** Every silver recipe keyed by the obj it makes, which is how a slot finds its recipe. */ +internal val silverProducts: Map by lazy { + CraftingSilverRow.all().associate { it.output.internalName to it.toCraftingProduct() } +} + +private const val INTERFACE_SILVER_CRAFTING = "interface.silver_crafting" + +/** Last-selected-item varbit, which is what the highlight box reads. */ +private const val VARBIT_SILVER_LASTTYPE = "varbit.crafting_silver_item_lasttype" + +/** A component of the silver crafting interface, by its name in the interface JSON. */ +private fun silverComponent(name: String): String = "component.silver_crafting:$name" diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt new file mode 100644 index 000000000..8691e6ec5 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt @@ -0,0 +1,247 @@ +package org.rsmod.content.skills.crafting.interfaces + +import dev.openrune.ServerCacheManager +import dev.openrune.definition.type.widget.IfEvent +import java.awt.Color +import org.rsmod.api.attr.AttributeKey +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.ui.setColour +import org.rsmod.api.script.onIfModalButton +import org.rsmod.api.table.crafting.CraftingHandRow +import org.rsmod.content.skills.crafting.CraftingSection +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** The tanner interface predates cs2 and is packed in the legacy if1 format, so it has no onLoad + * hook to hang scripts on and every component has to be driven from here. */ +class TannerInterfaceScript : PluginScript() { + override fun ScriptContext.startup() { + for ((slot, row) in tannerSlotRows) { + for (op in TannerOp.entries) { + onIfModalButton(tannerSlotOp(slot.letter, op.suffix)) { + performTan(slot, row, op) + } + } + } + } +} + +/** Opens the tanner and paints the grid. */ +fun ProtectedAccess.openTanner(prices: TannerPrices = TannerPrices.Table) { + if (tannerSlotRows.isEmpty()) { + return + } + player.attr[TANNER_PRICES] = prices + ifOpenMainModal(INTERFACE_TANNER) + for ((slot, _) in tannerSlotRows) { + for (op in TannerOp.entries) { + ifSetEvents(tannerSlotOp(slot.letter, op.suffix), -1..-1, IfEvent.Op1) + } + } + renderTannerSlots(prices) +} + +/** What one tanner charges per hide. */ +class TannerPrices private constructor(private val bySlot: Map) { + /** What this tanner charges for one hide of [row]. */ + internal fun priceOf(slot: TannerSlot, row: TanningRecipe): Int = + bySlot[slot.letter] ?: row.cost + + companion object { + /** The tanning table's own prices, used by Ellis and the Crafting Guild tanner. */ + val Table: TannerPrices = TannerPrices(emptyMap()) + + fun of( + soft: Int, + hard: Int, + snakeskin: Int, + swampSnakeskin: Int, + dragonhide: Int, + ): TannerPrices = + TannerPrices( + mapOf( + 'a' to soft, + 'b' to hard, + 'c' to swampSnakeskin, + 'd' to snakeskin, + 'e' to dragonhide, + 'f' to dragonhide, + 'g' to dragonhide, + 'h' to dragonhide, + ), + ) + } +} + +/** Prices the open interface was opened with, falling back to the table when absent. */ +private val TANNER_PRICES = AttributeKey() + +/** Tans [row]'s hide, up to [requested], at [pricePerHide] coins each. */ +fun ProtectedAccess.tanHides( + row: TanningRecipe, + requested: Int, + pricePerHide: Int = row.cost, +): Int { + val hide = row.input + val held = inv.count(hide) + if (held == 0) { + mes("You don't have any ${itemName(hide)} to tan.") + return 0 + } + + val coinsHeld = inv.count(CraftingConstants.COINS) + val affordable = if (pricePerHide > 0) coinsHeld / pricePerHide else Int.MAX_VALUE + if (affordable == 0) { + mes("You haven't got enough coins to pay for ${itemName(row.output)}.") + return 0 + } + + val amount = minOf(requested, held, affordable) + if (amount <= 0) { + return 0 + } + + val totalCost = pricePerHide * amount + if (!invDel(inv, CraftingConstants.COINS, totalCost).success) { + return 0 + } + if (!invDel(inv, hide, amount).success) { + invAdd(inv, CraftingConstants.COINS, totalCost) + return 0 + } + invAdd(inv, row.output, amount) + + val message = + when { + requested > held -> "You have run out of ${itemName(hide)}." + amount < requested -> + "You haven't got enough coins to pay for ${itemName(row.output)}." + else -> "The tanner tans your ${itemName(hide)}." + } + mes(message) + return amount +} + +/** Handles one slot click. */ +private suspend fun ProtectedAccess.performTan( + slot: TannerSlot, + row: TanningRecipe, + op: TannerOp, +) { + val requested = + when (op) { + TannerOp.Tan1 -> 1 + TannerOp.Tan5 -> 5 + TannerOp.TanAll -> Int.MAX_VALUE + TannerOp.TanX -> countDialog().coerceAtLeast(0) + } + val prices = player.attr.getOrDefault(TANNER_PRICES, TannerPrices.Table) + val tanned = if (requested > 0) tanHides(row, requested, prices.priceOf(slot, row)) else 0 + if (tanned > 0) { + ifClose() + } else if (player.ui.containsModal(INTERFACE_TANNER)) { + renderTannerSlots(prices) + } +} + +/** Paints the eight slots, colouring each label by whether the hide is held. */ +private fun ProtectedAccess.renderTannerSlots(prices: TannerPrices) { + for ((slot, row) in tannerSlotRows) { + val name = tannerSlotName(slot.letter) + val price = tannerSlotPrice(slot.letter) + ifSetObj(tannerSlotModel(slot.letter), slot.input, TANNER_MODEL_ZOOM) + ifSetText(name, slot.label) + ifSetText(price, coins(prices.priceOf(slot, row))) + val colour = if (inv.count(slot.input) > 0) LABEL_AVAILABLE else LABEL_UNAVAILABLE + player.setColour(name, colour) + player.setColour(price, colour) + } +} + +/** One slot of the grid. */ +internal data class TannerSlot( + val letter: Char, + val label: String, + val input: String, + val output: String, +) + +/** Which op stack position was clicked. */ +internal enum class TannerOp(val suffix: String) { + Tan1("1"), + Tan5("5"), + TanX("x"), + TanAll("all"), +} + +/** The grid in display order, with the top row a to d and the bottom row e to h. */ +private val TANNER_SLOTS = + listOf( + TannerSlot('a', "Soft leather", "obj.cow_hide", "obj.leather"), + TannerSlot('b', "Hard leather", "obj.cow_hide", "obj.hard_leather"), + TannerSlot('c', "Snakeskin", "obj.templetrek_swamp_snake_hide", "obj.village_snake_skin"), + TannerSlot('d', "Snakeskin", "obj.village_snake_hide", "obj.village_snake_skin"), + TannerSlot('e', "Green d'hide", "obj.dragonhide_green", "obj.dragon_leather"), + TannerSlot('f', "Blue d'hide", "obj.dragonhide_blue", "obj.dragon_leather_blue"), + TannerSlot('g', "Red d'hide", "obj.dragonhide_red", "obj.dragon_leather_red"), + TannerSlot('h', "Black d'hide", "obj.dragonhide_black", "obj.dragon_leather_black"), + ) + +private const val INTERFACE_TANNER = "interface.tanner" + +/** Model zoom for the tanner interface's slot model components. Higher values = larger */ +private const val TANNER_MODEL_ZOOM = 260 + +/** Label colour when the hide is held, matching the blue baked into the interface JSON. */ +private val LABEL_AVAILABLE: Color = Color(0, 207, 255) + +/** Label colour when the player has none of the required hide. */ +private val LABEL_UNAVAILABLE: Color = Color(254, 0, 0) + +/** One tanning exchange off a `crafting_hand` row, being a hide plus coins for one leather. */ +data class TanningRecipe(val input: String, val output: String, val cost: Int) + +/** Every tanning row. Thakkrad's yak curing reads this catalogue too. */ +internal val tanningRecipes: List by lazy { + CraftingHandRow.all() + .filter { it.section == CraftingSection.TANNING.id } + .map { TanningRecipe(it.input.first().internalName, it.output.first().internalName, it.cost ?: 0) } +} + +/** Slots paired with their tanning recipes, which the NPC scripts also iterate. */ +internal val tannerSlotRows: List> by lazy { + TANNER_SLOTS.mapNotNull { slot -> + val row = tanningRecipes.firstOrNull { it.input == slot.input && it.output == slot.output } + row?.let { slot to it } + } +} + +/** Tannable hide objs, used by the NPC used item on and greeting logic. */ +internal val tannableHideObjs: Set by lazy { TANNER_SLOTS.map { it.input }.toSet() } + +/** The leather objs tanning produces. */ +internal val tannedLeatherObjs: Set by lazy { TANNER_SLOTS.map { it.output }.toSet() } + +/** Coin count worded for chat. */ +private fun coins(amount: Int): String = if (amount == 1) "1 coin" else "$amount coins" + +/** Lowercased item name, falling back to the raw gameval. */ +internal fun itemName(internal: String): String { + val id = CraftingGamevals.objOrNull(internal) ?: return internal + return ServerCacheManager.getItem(id)?.name?.lowercase() ?: internal +} + +// The tanner interface's 4x2 hide grid uses one slot group per letter a to h. Each group has +// a model, a name text, a price text, and four op buttons for Tan 1, 5, X and All. +private fun tannerSlotModel(letter: Char): String = "component.tanner:tanning_${letter}_model" + +private fun tannerSlotName(letter: Char): String = "component.tanner:tanning_${letter}_text" + +private fun tannerSlotPrice(letter: Char): String = "component.tanner:tanning_${letter}_price" + +/** [op] is one of "1", "5", "x", "all". */ +private fun tannerSlotOp(letter: Char, op: String): String = + "component.tanner:tanning_${letter}_$op" + diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/BucketOfSandScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/BucketOfSandScript.kt new file mode 100644 index 000000000..65b67d6aa --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/BucketOfSandScript.kt @@ -0,0 +1,16 @@ +package org.rsmod.content.skills.crafting.items + +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpHeld4 +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class BucketOfSandScript : PluginScript() { + override fun ScriptContext.startup() { + onOpHeld4("obj.bucket_sand") { emptyBucket() } + } + + private fun ProtectedAccess.emptyBucket() { + invReplace(inv, "obj.bucket_sand", 1, "obj.bucket_empty") + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/CraftingCapeScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/CraftingCapeScript.kt new file mode 100644 index 000000000..2d6c04b19 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/CraftingCapeScript.kt @@ -0,0 +1,86 @@ +package org.rsmod.content.skills.crafting.items + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import org.rsmod.api.player.hook.TeleportType +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.baseCraftingLvl +import org.rsmod.api.script.onOpHeld3 +import org.rsmod.api.script.onOpWorn2 +import org.rsmod.api.script.onOpWorn3 +import org.rsmod.api.script.onPlayerQueue +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.map.CoordGrid +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** The crafting cape and its trimmed variant, which boost Crafting and teleport to the guild. */ +class CraftingCapeScript : PluginScript() { + override fun ScriptContext.startup() { + for (cape in CraftingConstants.CRAFTING_SKILLCAPES) { + onOpHeld3(cape) { startTeleport() } + onOpWorn3(cape) { startTeleport() } + onOpWorn2(cape) { boostCrafting() } + } + onPlayerQueue(TELEPORT_QUEUE) { finishTeleport() } + } + + private fun ProtectedAccess.boostCrafting() { + if (!isMasterCrafter()) { + return + } + statBoost(CraftingConstants.STAT_CRAFTING, constant = CAPE_BOOST, percent = 0) + } + + private fun ProtectedAccess.startTeleport() { + if (actionDelay > mapClock) { + return + } + + if (!isMasterCrafter()) { + return + } + + actionDelay = mapClock + TELEPORT_ACTION_DELAY + anim(TELEPORT_START_ANIM) + spotanim(TELEPORT_SPOTANIM, height = TELEPORT_SPOTANIM_HEIGHT) + soundSynth(TELEPORT_SOUND) + clearQueue(TELEPORT_QUEUE) + queue(TELEPORT_QUEUE, TELEPORT_DELAY) + } + + private fun ProtectedAccess.finishTeleport() { + telejump(GUILD_TELEPORT, TeleportType.Standard) + resetAnim() + } + + /** Both cape options are gated on the base level, so a drain cannot lock the player out. */ + private fun ProtectedAccess.isMasterCrafter(): Boolean { + if (player.baseCraftingLvl >= CraftingConstants.MAX_CRAFTING_LEVEL) { + return true + } + mes("You need to have a crafting level of ${CraftingConstants.MAX_CRAFTING_LEVEL}.") + return false + } + + private companion object { + /** skillcape teleport target */ + private val GUILD_TELEPORT = CoordGrid(2931, 3286, 0) + + private val TELEPORT_START_ANIM = RSCM.getReverseMapping(RSCMType.SEQ, 714) + private val TELEPORT_SPOTANIM = RSCM.getReverseMapping(RSCMType.SPOTANIM, 111) + + private const val TELEPORT_SOUND = "synth.teleport_all" + private const val TELEPORT_QUEUE = "queue.crafting_cape_teleport" + private const val TELEPORT_SPOTANIM_HEIGHT = 92 + + /** Levels the cape's boost option gives. */ + private const val CAPE_BOOST = 1 + + /** Ticks the teleport animation plays before the player is moved. */ + private const val TELEPORT_DELAY = 4 + + /** Ticks the player is locked out of re-triggering the teleport. */ + private const val TELEPORT_ACTION_DELAY = 5 + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/ImcandoHammerScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/ImcandoHammerScript.kt new file mode 100644 index 000000000..b4b017a5c --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/items/ImcandoHammerScript.kt @@ -0,0 +1,57 @@ +package org.rsmod.content.skills.crafting.items + +import dev.openrune.util.Wearpos +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.worn.WornUnequipResult +import org.rsmod.api.script.onOpHeld3 +import org.rsmod.api.script.onOpWorn4 +import org.rsmod.game.inv.Inventory +import org.rsmod.game.inv.isType +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class ImcandoHammerScript : PluginScript() { + override fun ScriptContext.startup() { + onOpHeld3(STANDARD) { invReplace(inv, STANDARD, 1, OFFHAND) } + onOpHeld3(OFFHAND) { invReplace(inv, OFFHAND, 1, STANDARD) } + + onOpWorn4(STANDARD) { swapWorn(it.slot, STANDARD, OFFHAND, Wearpos.LeftHand) } + onOpWorn4(OFFHAND) { swapWorn(it.slot, OFFHAND, STANDARD, Wearpos.RightHand) } + } + + /** Swaps a worn [fromType] for [toType], re-equipping into [targetWearpos] when it is free. */ + private fun ProtectedAccess.swapWorn( + fromSlot: Int, + fromType: String, + toType: String, + targetWearpos: Wearpos, + ) { + if (worn[fromSlot]?.isType(fromType) != true) { + return + } + // Read before unequipping, which can push the hammer into the slot we want to fill. + val targetFree = worn[targetWearpos.slot] == null + + val unequip = wornUnequip(fromSlot) + if (unequip is WornUnequipResult.Fail) { + unequip.message?.let(::mes) + return + } + invReplace(inv, fromType, 1, toType) + + if (targetFree) { + val swapped = inv.slotOf(toType) + if (swapped != null) { + invEquip(swapped) + } + } + } + + private companion object { + private const val STANDARD = "obj.imcando_hammer" + private const val OFFHAND = "obj.imcando_hammer_offhand" + } +} + +/** The first inventory slot holding [type], or null when the player has none. */ +private fun Inventory.slotOf(type: String): Int? = indices.firstOrNull { slot -> this[slot]?.isType(type) == true } diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt new file mode 100644 index 000000000..787315352 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt @@ -0,0 +1,183 @@ +package org.rsmod.content.skills.crafting.npcs + +import jakarta.inject.Inject +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.stat.baseCraftingLvl +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.content.skills.crafting.ownsCraftingHood +import org.rsmod.content.skills.crafting.ownsCraftingSkillcape +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class CraftingGuildMasterCrafter @Inject constructor() : PluginScript() { + + override fun ScriptContext.startup() { + onOpNpc1(MASTER_CRAFTER) { startDialogue(it.npc) { caped() } } + onOpNpc1(MASTER_CRAFTER_CAPELESS) { startDialogue(it.npc) { capeless() } } + onOpNpc1(MASTER_CRAFTER_YOUNG) { startDialogue(it.npc) { young() } } + } + + private suspend fun Dialogue.caped() { + if (player.baseCraftingLvl >= 99) { + capedAt99() + } else { + capedBelow99() + } + } + + private suspend fun Dialogue.capedBelow99() { + chatNpc( + happy, + "Hello, and welcome to the Crafting Guild. Accomplished crafters from all over the land " + + "come here to use our top notch workshops.", + ) + when ( + choice2( + "Yes.", + 1, + "No.", + 2, + title = "Would you like to ask about a Skillcape of Crafting?", + ) + ) { + 1 -> { + chatPlayer(quiz, "Hey, what is that cape you're wearing? I don't recognise it.") + chatNpc( + happy, + "This? This is a Skillcape of Crafting. It is a symbol of my ability as master of " + + "the Crafting Guild and it provides unlimited teleports here.", + ) + chatNpc( + happy, + "If you should ever achieve level 99 Crafting come and talk to me and we'll see if " + + "we can sort you out with one.", + ) + } + 2 -> {} + } + } + + private suspend fun Dialogue.capedAt99() { + chatNpc( + happy, + "Hello, and welcome to the Crafting Guild. Accomplished crafters from all over the land " + + "come here to use our top notch workshops.", + ) + + if (!player.ownsCraftingSkillcape()) { + purchaseSkillcape() + return + } + when (choice2("Skillcape", 1, "Hood", 2)) { + 1 -> chatNpc(neutral, "You've already got a Skillcape of Crafting!") + 2 -> offerFreeHood() + } + } + + private suspend fun Dialogue.purchaseSkillcape() { + chatPlayer(quiz, "Are you the person I need to talk to about buying a Skillcape of Crafting?") + chatNpc( + happy, + "I certainly am, and I can see that you are definitely talented enough to own one! The " + + "cape has a built-in teleport back to us at the guild too!", + ) + chatNpc( + neutral, + "Unfortunately, being such a prestigious item, they are appropriately expensive. I'm " + + "afraid I must ask you for $CRAFTING_CAPE_PRICE gold.", + ) + when (choice2("$CRAFTING_CAPE_PRICE gold! Are you mad?", 1, "That's fine.", 2)) { + 1 -> { + chatPlayer(neutral, "$CRAFTING_CAPE_PRICE gold! Are you mad?") + chatNpc( + neutral, + "Not at all; there are many other adventurers who would love the opportunity to " + + "purchase such a prestigious item! You can find me here if you change your mind.", + ) + } + 2 -> completeSkillcapePurchase() + } + } + + private suspend fun Dialogue.completeSkillcapePurchase() { + if (access.inv.count("obj.coins") < CRAFTING_CAPE_PRICE) { + chatPlayer(neutral, "That's fine.") + chatPlayer(sad, "But, unfortunately, I don't have enough money with me.") + chatNpc(neutral, "Well, come back and see me when you do.") + return + } + if (access.inv.freeSpace() < 2) { + chatNpc( + neutral, + "Unfortunately all Skillcapes are only available with a free hood, it's part of " + + "a skill promotion deal; buy one get one free, you know. So you'll need to " + + "free up some inventory space before I can sell you one.", + ) + return + } + val coinDel = access.invDel(access.inv, "obj.coins", count = CRAFTING_CAPE_PRICE, strict = true) + if (coinDel.failure) { + chatNpc(neutral, "Well, come back and see me when you do.") + return + } + val capeAdd = access.invAdd(access.inv, CRAFTING_CAPE, 1) + val hoodAdd = access.invAdd(access.inv, CRAFTING_HOOD, 1) + if (capeAdd.failure || hoodAdd.failure) { + access.invAdd(access.inv, "obj.coins", CRAFTING_CAPE_PRICE) + chatNpc( + neutral, + "Unfortunately all Skillcapes are only available with a free hood, it's part of " + + "a skill promotion deal; buy one get one free, you know. So you'll need to " + + "free up some inventory space before I can sell you one.", + ) + return + } + chatPlayer(neutral, "That's fine.") + chatNpc(happy, "Excellent! Wear that cape with pride my friend.") + } + + private suspend fun Dialogue.offerFreeHood() { + chatPlayer(quiz, "May I have another hood for my cape, please?") + if (player.ownsCraftingHood()) { + chatNpc(angry, "You've already got one!") + return + } + chatNpc(happy, "Most certainly, and free of charge!") + if (access.inv.freeSpace() < 1) { + chatNpc(neutral, "You'll need a free inventory slot before I can hand you the hood.") + return + } + val add = access.invAdd(access.inv, CRAFTING_HOOD, 1) + if (add.failure) { + chatNpc(neutral, "You'll need a free inventory slot before I can hand you the hood.") + } + } + + private suspend fun Dialogue.capeless() { + chatNpc( + happy, + "Hello, and welcome to the Crafting Guild. Accomplished crafters from all over the land " + + "come here to use our top notch workshops.", + ) + } + + private suspend fun Dialogue.young() { + chatNpc(neutral, "Yeah?") + chatPlayer(neutral, "Hello.") + chatNpc(neutral, "Whassup?") + chatPlayer(quiz, "So... are you here to give crafting tips?") + chatNpc(neutral, "Dude, do I look like I wanna talk to you?") + chatPlayer(neutral, "I suppose not.") + chatNpc(happy, "Right on!") + } + + private companion object { + private const val MASTER_CRAFTER = "npc.master_crafter" + private const val MASTER_CRAFTER_CAPELESS = "npc.master_crafter_2" + private const val MASTER_CRAFTER_YOUNG = "npc.master_crafter_3" + + private const val CRAFTING_CAPE = "obj.skillcape_crafting" + private const val CRAFTING_HOOD = "obj.skillcape_crafting_hood" + private const val CRAFTING_CAPE_PRICE = 99_000 + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingTutorScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingTutorScript.kt new file mode 100644 index 000000000..7fb02d82c --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingTutorScript.kt @@ -0,0 +1,321 @@ +package org.rsmod.content.skills.crafting.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** The Crafting tutor (`aide_tutor_crafting`). */ +class CraftingTutorScript : PluginScript() { + + override fun ScriptContext.startup() { + if (!CraftingGamevals.exists(CraftingConstants.CRAFTING_TUTOR)) { + return + } + onOpNpc1(CraftingConstants.CRAFTING_TUTOR) { greet(it.npc) } + } + + private suspend fun ProtectedAccess.greet(npc: Npc) { + startDialogue(npc) { + chatPlayer(happy, "Hello.") + chatNpc(happy, "Hello there! Are you interested in hearing all about crafting?") + mainMenu() + } + } + + /** The top-level menu. Loops until the player leaves (or has heard everything, at which point the only option left is to leave anyway). */ + private suspend fun Dialogue.mainMenu() { + val asked = mutableSetOf() + while (true) { + val remaining = MainTopic.entries.filter { it !in asked } + val options = remaining.map { it.option to it } + ("No, thank you." to null) + val pick = chooseFrom(options) + if (pick == null) { + farewell() + return + } + asked += pick + when (pick) { + MainTopic.Training -> trainingAdvice() + MainTopic.Craftables -> craftableTopics() + } + } + } + + private suspend fun Dialogue.farewell() { + chatPlayer(neutral, "No, thank you.") + chatNpc(happy, "Well, just come back any time you want to know anything!") + } + + private suspend fun Dialogue.trainingAdvice() { + chatPlayer(quiz, MainTopic.Training.option) + when (access.statBase(CraftingConstants.STAT_CRAFTING)) { + in Int.MIN_VALUE..9 -> beginnerAdvice() + in 10..19 -> noviceAdvice() + else -> experiencedAdvice() + } + chatPlayer(happy, "Thanks!") + chatNpc(quiz, "Is there anything else you want to know?") + } + + private suspend fun Dialogue.beginnerAdvice() { + chatNpc( + neutral, + "To get started, you might like to try your hand at crafting some armour from cow's " + + "leather. There's a tanner just over in Al Kharid who could tan any hides for you.", + ) + chatNpc( + neutral, + "Once you've managed to get your hand on some kind of leather, you just need a needle " + + "and some thread to get to work!", + ) + chatNpc( + neutral, + "If that doesn't take your fancy, you could try your hand at spinning wool right here " + + "on the spinning wheel.", + ) + chatNpc( + neutral, + "Come to think of it... Farmer Fred, just north of here, sounds like he could use a " + + "hand shearing some of his sheep. Maybe you could help him out, and get some " + + "practice while you're at it.", + ) + } + + private suspend fun Dialogue.noviceAdvice() { + chatNpc( + neutral, + "Lots of budding craftsmen seem to spend their time here spinning Flax into bow " + + "strings on the wheel, if that takes your fancy.", + ) + chatNpc( + neutral, + "Or you might like to try your hand at crafting some armour from cow's leather. " + + "There's a tanner just over in Al Kharid who could tan any hides for you.", + ) + } + + private suspend fun Dialogue.experiencedAdvice() { + if (access.statBase(CraftingConstants.STAT_CRAFTING) >= CraftingConstants.MAX_CRAFTING_LEVEL) { + chatNpc( + happy, + "I can't help but feel like I should be the one asking you! At this point, the " + + "world's your oyster!", + ) + } else { + chatNpc(happy, "Now that you've got a little experience under your belt, the world's your oyster!") + } + chatNpc( + neutral, + "If you wanted to try your hand at making some jewellery, you'll want to get your hand " + + "on some gold and some kind of gem, with a mould to work with and a chisel at hand.", + ) + chatNpc(neutral, "But if that's not your thing, maybe you'd like to try making some more armour!") + chatNpc( + neutral, + "Cow's leather's a good place to start, but you can try all other kinds of hides, like " + + "a dragon's!", + ) + chatNpc( + neutral, + "Glass blowing seems to be a popular trade, too. You can forge molten glass from some " + + "sand and soda ash, then make all kinds of things using a glass blowing pipe.", + ) + } + + /** The five craftable topics. Loops until the player picks "Not right now." */ + private suspend fun Dialogue.craftableTopics() { + chatPlayer(quiz, MainTopic.Craftables.option) + chatNpc( + happy, + "All kinds of things, really! You can make armour from leather, make some pottery, try " + + "your hand at glass blowing, make some jewellery, weapons even...", + ) + chatNpc(quiz, "What would you like to hear about?") + + val asked = mutableSetOf() + while (true) { + val remaining = CraftTopic.entries.filter { it !in asked } + val topics = remaining.map { it.option to it } + // "Not right now." is only offered once he's actually told the player something. + val options: List> + if (asked.isEmpty()) { + options = topics + } else { + options = topics + ("Not right now." to null) + } + + val pick = chooseFrom(options) + if (pick == null) { + chatPlayer(neutral, "Not right now.") + chatNpc(quiz, "Is there anything else you want to know?") + return + } + asked += pick + chatPlayer(quiz, pick.option) + when (pick) { + CraftTopic.Armour -> armour() + CraftTopic.Pottery -> pottery() + CraftTopic.GlassBlowing -> glassBlowing() + CraftTopic.Jewellery -> jewellery() + CraftTopic.Weapons -> weapons() + } + chatNpc(quiz, "Would you like to hear about anything else I mentioned?") + } + } + + private suspend fun Dialogue.armour() { + chatNpc( + neutral, + "Sure thing! Most armour you can craft just involves taking a needle and some thread " + + "to whatever material you can get your hands on.", + ) + chatNpc( + neutral, + "You might like to try with different kinds of animal hide, from cows, yaks, snakes, " + + "dragons... Or any other sort of fabric you can find!", + ) + chatNpc(neutral, "Some crafty types have started making some leather-covered wooden shields recently, too!") + chatNpc( + neutral, + "You might find some creatures make for a pretty sturdy helmet, too. The best way to " + + "make something like that's just to take a good old chisel to it!", + ) + chatPlayer(happy, "That sounds great.") + } + + private suspend fun Dialogue.pottery() { + chatNpc(neutral, "Of course! Pottery's all kind of the same, it just takes a lot of getting used to.") + chatNpc( + neutral, + "All that there really is to it is getting your hands on some clay, getting it wet and " + + "getting to work on a potter's wheel.", + ) + chatNpc( + neutral, + "Once you've got the shape you want out of it, you want to put it in a pottery oven to " + + "fire it up.", + ) + chatNpc( + neutral, + "The barbarians west of Varrock seem to be pretty keen on their pottery. I'd take a " + + "look around there, if you wanted to try it out!", + ) + chatNpc(neutral, "Once you get the hang of it, you can make pots, pie dishes, bowls... You know.") + chatPlayer(happy, "Neat!") + } + + private suspend fun Dialogue.glassBlowing() { + chatNpc( + neutral, + "Glass blowing? Well, to get started you'll need to get yourself some molten glass. You " + + "can make some for yourself by heating sand and soda ash in a furnace.", + ) + chatNpc( + neutral, + "Once you've got a hold of that, you'll want a glass blowing pipe to blow it out in to " + + "shape, before it cools down.", + ) + chatNpc(neutral, "It's not the easiest thing to get to grips with, but it shouldn't be too difficult!") + chatPlayer(happy, "Nifty.") + } + + private suspend fun Dialogue.jewellery() { + chatNpc( + neutral, + "Sure. All it boils down to is a bar of gold or silver, some kind of cut gem if you're " + + "feeling fancy, and a mould to help shape the thing.", + ) + chatNpc( + neutral, + "You can use a chisel to make a gem really shine and sparkle. You wouldn't want to use " + + "an uncut one for any jewellery.", + ) + chatNpc( + neutral, + "Once you've got all of your materials together, you'll want to use a furnace to " + + "actually craft whatever it is you're trying to make!", + ) + chatNpc( + neutral, + "Wizards seem to like enchanting their jewellery to do all kinds of things, too, but I " + + "don't know a lot about that.", + ) + chatPlayer(happy, "Sounds good!") + } + + private suspend fun Dialogue.weapons() { + chatNpc( + neutral, + "Well, there aren't too many weapons that people tend to craft, but there are a couple " + + "worth noting!", + ) + chatNpc( + neutral, + "Battlestaves are probably the most common. It boils down to fastening an orb of some " + + "kind onto the end of a battlestaff.", + ) + chatNpc( + neutral, + "You do need to get the orb in the first place, though... You can make one through " + + "glass blowing, but it's not much use without being charged.", + ) + chatNpc( + neutral, + "I can't help much with crafting it, though! There's a little more magic involved there " + + "than I'm familiar with.", + ) + chatNpc( + neutral, + "Aside from battlestaves, there are a couple of weapons that people like to make from " + + "silver... Sickles and bolts, usually.", + ) + chatPlayer(happy, "Interesting...") + } + + /** + * Presents a prunable menu. + */ + private suspend fun Dialogue.chooseFrom(options: List>): T = + when (options.size) { + 1 -> options[0].second + 2 -> choice2(options[0].first, options[0].second, options[1].first, options[1].second) + 3 -> choice3( + options[0].first, options[0].second, + options[1].first, options[1].second, + options[2].first, options[2].second, + ) + 4 -> choice4( + options[0].first, options[0].second, + options[1].first, options[1].second, + options[2].first, options[2].second, + options[3].first, options[3].second, + ) + else -> choice5( + options[0].first, options[0].second, + options[1].first, options[1].second, + options[2].first, options[2].second, + options[3].first, options[3].second, + options[4].first, options[4].second, + ) + } + + /** The two things the tutor can be asked about at the top level. */ + private enum class MainTopic(val option: String) { + Training("How can I train my crafting?"), + Craftables("What kinds of things can be crafted?"), + } + + /** The five things he can talk through under "What kinds of things can be crafted?". */ + private enum class CraftTopic(val option: String) { + Armour("Tell me about crafting armour."), + Pottery("Tell me about pottery."), + GlassBlowing("Tell me about glass blowing."), + Jewellery("Tell me about making jewellery."), + Weapons("Tell me about crafting weapons."), + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt new file mode 100644 index 000000000..a25c6808f --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt @@ -0,0 +1,168 @@ +package org.rsmod.content.skills.crafting.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.script.onOpNpcU +import org.rsmod.content.skills.crafting.interfaces.TannerPrices +import org.rsmod.content.skills.crafting.interfaces.openTanner +import org.rsmod.content.skills.crafting.interfaces.tannableHideObjs +import org.rsmod.content.skills.crafting.interfaces.tannedLeatherObjs +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class EodanScript : PluginScript() { + + override fun ScriptContext.startup() { + if (!CraftingGamevals.exists(TANNER_EODAN)) { + return + } + onOpNpc1(TANNER_EODAN) { greet(it.npc) } + onOpNpc3(TANNER_EODAN) { openTanner(eodanPrices()) } + onOpNpcU(TANNER_EODAN) { event -> usedItemOnEodan(event.npc, event.objType.internalName) + } + } + + private suspend fun ProtectedAccess.greet(npc: Npc) { + startDialogue(npc) { + chatNpc(happy, "Hello, thanks for rescuing me. Is there anything I can do for you?") + options() + } + } + + /** The option menu, re-shown after either lore branch. */ + private suspend fun Dialogue.options() { + while (true) { + val pick = choice4( + "How did you end up down here?", EodanChoice.HowStuck, + "Now that you're free, why don't you leave?", EodanChoice.WhyStay, + "Can you tan some hides for me?", EodanChoice.Tan, + "I'm good thanks.", EodanChoice.Leave, + ) + when (pick) { + EodanChoice.HowStuck -> howStuck() + EodanChoice.WhyStay -> whyStay() + EodanChoice.Tan -> { + access.openTanner(access.eodanPrices()) + return + } + EodanChoice.Leave -> return + } + } + } + + private suspend fun Dialogue.howStuck() { + chatPlayer(quiz, "How did you end up down here?") + chatNpc( + neutral, + "I travelled down here with my friend Olbertus in search for treasure. When we didn't " + + "find anything except his strange structure, he decided to prise off a coin from the " + + "stone relief in the other room. Before I knew it the entrance had closed and I was " + + "stuck down here.", + ) + chatNpc( + sad, + "I tried calling out to Olbertus but he must not have heard me. I don't know what " + + "would've happened if you hadn't shown up and saved me.", + ) + chatPlayer( + neutral, + "Well, it turns out Olbertus was corrupted by the coin he stole, I managed to get the " + + "coin from him and returned it to the relief, he should be fine now.", + ) + chatNpc(happy, "That's good news at least.") + chatNpc( + happy, + "I am quite the proficient tanner, for helping me escape I will offer to tan hides for " + + "you. However, they'll be at a slightly higher cost for the convenience of being " + + "closer to the source.", + ) + } + + private suspend fun Dialogue.whyStay() { + chatPlayer(quiz, "Now that you're free, why don't you leave?") + chatNpc( + neutral, + "To be honest, I tried to set up a Tannery on the surface but business was poor, there " + + "aren't a lot of dragons in Kourend.", + ) + chatNpc( + angry, + "I even heard a rumour that people have learned to tan hides with magic. It's always " + + "the same! Magic users stealing jobs from honest tradesman!", + ) + if (access.canCastTanLeather()) { + chatPlayer(shifty, "Erm... Yeah! Those magic users...") + } else { + chatPlayer(shocked, "Oh, wow! I can see why that wouldn't help business.") + } + chatNpc( + neutral, + "Anyway, I figured if I was closer to the source of the hides then I might get more " + + "business. So I'll try setting up shop down here for a while.", + ) + } + + private suspend fun ProtectedAccess.usedItemOnEodan(npc: Npc, obj: String?) { + when { + obj != null && obj in tannableHideObjs -> openTanner(eodanPrices()) + obj != null && obj in tannedLeatherObjs -> + startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } + else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } + } + } + + private enum class EodanChoice { HowStuck, WhyStay, Tan, Leave } +} + +/** Eodan's price schedule per Kourend & Kebos diary tier. */ +private enum class EodanTier(val prices: TannerPrices) { + None(TannerPrices.of(soft = 10, hard = 30, snakeskin = 200, swampSnakeskin = 150, dragonhide = 200)), + Easy(TannerPrices.of(soft = 8, hard = 24, snakeskin = 160, swampSnakeskin = 120, dragonhide = 160)), + Medium(TannerPrices.of(soft = 6, hard = 18, snakeskin = 120, swampSnakeskin = 90, dragonhide = 120)), + Hard(TannerPrices.of(soft = 4, hard = 12, snakeskin = 80, swampSnakeskin = 60, dragonhide = 80)), + Elite(TannerPrices.of(soft = 2, hard = 6, snakeskin = 40, swampSnakeskin = 30, dragonhide = 40)), +} + +/** Loops through to find the highest tier of diary completed. */ +private fun ProtectedAccess.eodanTier(): EodanTier { + val tiers = listOf(EodanTier.Easy, EodanTier.Medium, EodanTier.Hard, EodanTier.Elite) + var highest = EodanTier.None + for ((index, varbit) in KOUREND_DIARY_VARBITS.withIndex()) { + if (!CraftingGamevals.exists(varbit)) { + continue + } + if (player.vars[varbit] != 0) { + highest = tiers[index] + } + } + return highest +} + +private fun ProtectedAccess.eodanPrices(): TannerPrices = eodanTier().prices + +/** Lunar spellbook and 78 Magic, the requirements for Tan Leather, which he grumbles about. */ +private fun ProtectedAccess.canCastTanLeather(): Boolean { + if (!CraftingGamevals.exists(VARBIT_SPELLBOOK)) { + return false + } + val lunar = player.vars[VARBIT_SPELLBOOK] == SPELLBOOK_LUNAR + return lunar && statBase("stat.magic") >= 78 +} + +private const val TANNER_EODAN = "npc.hosdun_eodan" + +private val KOUREND_DIARY_VARBITS: List = listOf( + "varbit.kourend_diary_easy_complete", + "varbit.kourend_diary_medium_complete", + "varbit.kourend_diary_hard_complete", + "varbit.kourend_diary_elite_complete", +) + +/** Active spellbook varbit, where 2 is Lunar. */ +private const val VARBIT_SPELLBOOK = "varbit.spellbook" + +private const val SPELLBOOK_LUNAR = 2 diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt new file mode 100644 index 000000000..500f9b891 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt @@ -0,0 +1,161 @@ +package org.rsmod.content.skills.crafting.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc2 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.script.onOpNpcU +import org.rsmod.content.skills.crafting.interfaces.TannerPrices +import org.rsmod.content.skills.crafting.interfaces.openTanner +import org.rsmod.content.skills.crafting.interfaces.tannableHideObjs +import org.rsmod.content.skills.crafting.interfaces.tannedLeatherObjs +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Ellis (Al Kharid), Chouani (Kourend), and the Crafting Guild Tanner share almost everything + * the only difference being the greeting/offer/decline lines, so they share this script. + */ +class LeatherTannerScript : PluginScript() { + + private val tanners: List = listOf( + Tanner(TANNER_ELLIS, LeatherManufacturerFlow, TannerPrices.Table), + Tanner(TANNER_GUILD, LeatherManufacturerFlow, TannerPrices.Table), + // Chouani/Sbott share prices + Tanner(TANNER_CHOUANI, ChouaniFlow, SBOTT_PRICES), + ) + + override fun ScriptContext.startup() { + for ((npc, flow, prices) in tanners) { + if (!CraftingGamevals.exists(npc)) { + continue + } + // op1 is Talk-to + // op2/op3 = Trade (Technically only op3, but also op2 just in case) + onOpNpc1(npc) { flow.greet(this, it.npc, prices) } + onOpNpc2(npc) { openTanner(prices) } + onOpNpc3(npc) { openTanner(prices) } + onOpNpcU(npc) { event -> usedItemOnTanner(event.npc, event.objType?.internalName, prices) } + } + } + + /** Item-on-tanner interaction */ + private suspend fun ProtectedAccess.usedItemOnTanner(npc: Npc, obj: String?, prices: TannerPrices) { + when { + obj != null && obj in tannableHideObjs -> openTanner(prices) + obj != null && obj in tannedLeatherObjs -> startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } + else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } + } + } +} + +/** npc gameval, dialogue flow, and price schedule. */ +private data class Tanner( + val npc: String, + val flow: DialogueFlow, + val prices: TannerPrices, +) + +private interface DialogueFlow { + suspend fun greet(access: ProtectedAccess, npc: Npc, prices: TannerPrices) +} + +//Ellis and Crafting Guild tanner +private object LeatherManufacturerFlow : DialogueFlow { + override suspend fun greet(access: ProtectedAccess, npc: Npc, prices: TannerPrices) { + access.startDialogue(npc) { + chatNpc(happy, "Greetings friend. I am a manufacturer of leather.") + val hides = access.heldTannableHides() + if (hides > 0) { + offerTanning(hides, prices) + } else { + leatherSalesPitch() + } + } + } + + private suspend fun Dialogue.leatherSalesPitch() { + val buyLeather = choice2("Can I buy some leather then?", true, "Leather is rather weak stuff.", false) + if (buyLeather) { + chatPlayer(quiz, "Can I buy some leather then?") + chatNpc( + neutral, + "I make leather from animal hides. Bring me some cowhides and one gold coin per " + + "hide, and I'll tan them into soft leather for you.", + ) + } else { + chatPlayer(neutral, "Leather is rather weak stuff.") + chatNpc( + neutral, + "Normal leather may be quite weak, but it's very cheap - I make it from cowhides " + + "for only 1 gp per hide - and it's so easy to craft that anyone can work with it.", + ) + chatNpc( + neutral, + "Alternatively you could try hard leather. It's not so easy to craft, but I only " + + "charge 3 gp per cowhide to prepare it, and it makes much sturdier armour.", + ) + chatNpc( + neutral, + "I can also tan snake hides and dragonhides, suitable for crafting into the " + + "highest quality armour for rangers.", + ) + chatPlayer(happy, "Thanks, I'll bear it in mind.") + } + } +} + +//Chouani (Kourend) +private object ChouaniFlow : DialogueFlow { + override suspend fun greet(access: ProtectedAccess, npc: Npc, prices: TannerPrices) { + access.startDialogue(npc) { + chatNpc(happy, "Nilsal, iknami. Would you like me to tan any hides for you?") + if (access.heldTannableHides() == 0) { + chatPlayer(neutral, "No thanks. I don't have any hides.") + farewell() + return@startDialogue + } + if (choice2("Yes please.", true, "No thanks.", false)) { + chatPlayer(happy, "Yes please.") + access.openTanner(prices) + } else { + chatPlayer(neutral, "No thanks.") + farewell() + } + } + } + + private suspend fun Dialogue.farewell() { + chatNpc(neutral, "No problem, iknami. Come back if you need me to tan any hides for you.") + } +} + +/** "Do you want me to tan these?" branch. Used by [LeatherManufacturerFlow] */ +private suspend fun Dialogue.offerTanning(hides: Int, prices: TannerPrices) { + if (hides == 1) { + chatNpc(quiz, "I see you have brought me a hide. Would you like me to tan it for you?") + } else { + chatNpc(quiz, "I see you have brought me some hides. Would you like me to tan them for you?") + } + if (choice2("Yes please.", true, "No thanks.", false)) { + chatPlayer(happy, "Yes please.") + access.openTanner(prices) + } else { + chatPlayer(neutral, "No thanks.") + chatNpc(neutral, "Very well, ${access.sirMadam()}, as you wish.") + } +} + +private fun ProtectedAccess.heldTannableHides(): Int = tannableHideObjs.sumOf { inv.count(it) } +private fun ProtectedAccess.sirMadam(): String = if (isBodyTypeA()) "sir" else "madam" + +/** Ellis, the Al Kharid tanner. */ +private const val TANNER_ELLIS = "npc.ellis_tanner" + +private const val TANNER_GUILD = "npc.tanner" + +/** Chouani, the Great Kourend tanner. */ +private const val TANNER_CHOUANI = "npc.auburn_tanner" diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt new file mode 100644 index 000000000..0aafd434a --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt @@ -0,0 +1,130 @@ +package org.rsmod.content.skills.crafting.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.vars.boolVarBit +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.script.onOpNpcU +import org.rsmod.content.skills.crafting.interfaces.TannerPrices +import org.rsmod.content.skills.crafting.interfaces.openTanner +import org.rsmod.content.skills.crafting.interfaces.tannableHideObjs +import org.rsmod.content.skills.crafting.interfaces.tannedLeatherObjs +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.content.skills.crafting.util.hasCompletedQuest +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** Mary, on the farm north of Hosidius. */ +class MaryScript : PluginScript() { + + override fun ScriptContext.startup() { + if (!CraftingGamevals.exists(TANNER_MARY)) { + return + } + onOpNpc1(TANNER_MARY) { greet(it.npc) } + onOpNpc3(TANNER_MARY) { tan(it.npc) } + onOpNpcU(TANNER_MARY) { usedItemOnMary(it.npc, it.objType.internalName) } + } + + /** The pre-quest Mary, who has standard dialogue with no options and no tanning. */ + private suspend fun ProtectedAccess.preQuestChat(npc: Npc) { + startDialogue(npc) { + chatPlayer(quiz, "Hello there. Is this your home?") + chatNpc(neutral, "It is. What brings you here?") + chatPlayer(neutral, "I'm just looking around.") + chatNpc(neutral, "Well you won't find anything too exciting here I'm afraid. Just farming.") + chatPlayer(happy, "Well you have fun with it.") + } + } + + private suspend fun ProtectedAccess.greet(npc: Npc) { + if (!tansForPlayer()) { + preQuestChat(npc) + return + } + val firstMeeting = !player.seenGettingAheadDialogue + player.seenGettingAheadDialogue = true + + startDialogue(npc) { + if (firstMeeting) { + gettingAheadAftermath() + } else { + returningCustomer() + } + } + } + + /** The one-off conversation straight after Getting Ahead. */ + private suspend fun Dialogue.gettingAheadAftermath() { + chatNpc(neutral, "Well I have to say that mounted head looks awful.") + chatPlayer(neutral, "Sorry.") + chatNpc(happy, "Not to worry. If it keeps Gordon happy, so be it. Anyway, thank you for keeping our " + + "farm safe. With the beast dealt with, I've been able to get back to tanning again.", + ) + + val tan = choice2( "Could you tan something for me?", true, + "Happy to have helped. All the best.", false, + ) + if (tan) { + tanRequest() + } else { + chatPlayer(happy, "Happy to have helped. All the best.") + } + } + + /** Every conversation after the first. */ + private suspend fun Dialogue.returningCustomer() { + chatNpc(happy, "Good to see you again! Anything I can do for you?") + val tan = choice2("Could you tan something for me?", true, "I'm good.", false) + if (tan) { + tanRequest() + } else { + chatPlayer(neutral, "I'm good.") + } + } + + private suspend fun Dialogue.tanRequest() { + chatPlayer(quiz, "Could you tan something for me?") + chatNpc(happy, "Of course.") + access.openTanner(MARY_PRICES) + } + + private suspend fun ProtectedAccess.tan(npc: Npc) { + if (tansForPlayer()) { + openTanner(MARY_PRICES) + } else { + preQuestChat(npc) + } + } + + private suspend fun ProtectedAccess.usedItemOnMary(npc: Npc, obj: String?) { + when { + !tansForPlayer() -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } + obj != null && obj in tannableHideObjs -> openTanner(MARY_PRICES) + obj != null && obj in tannedLeatherObjs -> startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } + else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } + } + } + + private fun ProtectedAccess.tansForPlayer(): Boolean = player.hasCompletedQuest(QUEST_GETTING_AHEAD) +} + +private const val TANNER_MARY = "npc.ga_mary" + +private const val QUEST_GETTING_AHEAD = "quest_gettingahead" + +/** Set once Mary has given her post-Getting Ahead speech. */ +private const val VARBIT_MARY_DIALOGUE_SEEN = "varbit.ga_mary_dialogue" + +private var Player.seenGettingAheadDialogue by boolVarBit(VARBIT_MARY_DIALOGUE_SEEN) + +internal val MARY_PRICES: TannerPrices = TannerPrices.of( + soft = 1, + hard = 3, + snakeskin = 15, + swampSnakeskin = 20, + dragonhide = 20, +) diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt new file mode 100644 index 000000000..532bbdbdb --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt @@ -0,0 +1,127 @@ +package org.rsmod.content.skills.crafting.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.script.onOpNpcU +import org.rsmod.content.skills.crafting.interfaces.TannerPrices +import org.rsmod.content.skills.crafting.interfaces.openTanner +import org.rsmod.content.skills.crafting.interfaces.tannableHideObjs +import org.rsmod.content.skills.crafting.interfaces.tannedLeatherObjs +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** Sbott, the Canifis tanner. */ +class SbottScript : PluginScript() { + + override fun ScriptContext.startup() { + if (!CraftingGamevals.exists(TANNER_SBOTT)) { + return + } + onOpNpc1(TANNER_SBOTT) { greet(it.npc) } + onOpNpc3(TANNER_SBOTT) { openTanner(SBOTT_PRICES) } + onOpNpcU(TANNER_SBOTT) { event -> usedItemOnSbott(event.npc, event.objType.internalName) + } + } + + private suspend fun ProtectedAccess.greet(npc: Npc) { + startDialogue(npc) { + chatNpc(happy, "Hello stranger. Would you like me to tan any hides for you?") + chatNpcNoAnim( + "Soft leather - $SBOTT_SOFT_PRICE gp per hide
" + + "Hard leather - $SBOTT_HARD_PRICE gp per hide
" + + "Snakeskins - $SBOTT_SNAKESKIN_PRICE gp per hide
" + + "Dragon leather - $SBOTT_DRAGON_PRICE gp per hide.", + ) + if (access.heldTannableHides() > 0) { + offerWithHides() + } else { + offerEmptyHanded() + } + } + } + + private suspend fun Dialogue.offerWithHides() { + val pick = choice3( + "Yes please.", OfferChoice.Yes, + "Why are you so expensive?", OfferChoice.Why, + "No thanks, I'm not interested.", OfferChoice.No, + ) + when (pick) { + OfferChoice.Yes -> access.openTanner(SBOTT_PRICES) + OfferChoice.Why -> { + explainPricing() + if (choice2("Yes please.", true, "No thanks, I'm not interested.", false)) { + access.openTanner(SBOTT_PRICES) + } else { + notInterested() + } + } + OfferChoice.No -> notInterested() + } + } + + private suspend fun Dialogue.offerEmptyHanded() { + val expensive = choice2( + "Why are you so expensive?", true, + "No thanks, I haven't any hides.", false, + ) + if (expensive) { + explainPricing() + } + noHides() + } + + private suspend fun Dialogue.explainPricing() { + chatPlayer(quiz, "Why are you so expensive? The tanner in Al-Kharid is almost half the price!") + chatNpc( + happy, + "Hey, I charge more because I'm worth it! I deal in bulk, and I work extremely " + + "quickly. You'll see for yourself!", + ) + chatNpc(happy, "You got a lot of hides you want tanning quickly? I'm your guy!") + chatNpc(quiz, "So you got hides for me to tan, or are you just gonna bust my chops about prices all day?") + } + + private suspend fun Dialogue.notInterested() { + chatPlayer(neutral, "No thanks, I'm not interested.") + chatNpc(neutral, "Okay; you change your mind, you come see me. I'm your guy!") + } + + private suspend fun Dialogue.noHides() { + chatPlayer(neutral, "No thanks, I haven't any hides.") + chatNpc(neutral, "Fair enough. I can't tan what you don't bring me.") + } + + private suspend fun ProtectedAccess.usedItemOnSbott(npc: Npc, obj: String?) { + when { + obj != null && obj in tannableHideObjs -> openTanner(SBOTT_PRICES) + obj != null && obj in tannedLeatherObjs -> startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } + else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } + } + } + + private fun ProtectedAccess.heldTannableHides(): Int = + tannableHideObjs.sumOf { inv.count(it) } + + /** Options for the top level Yes, Why and No choice. Only used internally. */ + private enum class OfferChoice { Yes, Why, No } +} + +private const val SBOTT_SOFT_PRICE = 2 +private const val SBOTT_HARD_PRICE = 5 +private const val SBOTT_SNAKESKIN_PRICE = 25 +private const val SBOTT_DRAGON_PRICE = 45 + +internal val SBOTT_PRICES: TannerPrices = TannerPrices.of( + soft = SBOTT_SOFT_PRICE, + hard = SBOTT_HARD_PRICE, + snakeskin = SBOTT_SNAKESKIN_PRICE, + swampSnakeskin = SBOTT_DRAGON_PRICE, + dragonhide = SBOTT_DRAGON_PRICE, +) + +private const val TANNER_SBOTT = "npc.werewolftanner" diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/ThakkradScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/ThakkradScript.kt new file mode 100644 index 000000000..0afee882d --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/ThakkradScript.kt @@ -0,0 +1,132 @@ +package org.rsmod.content.skills.crafting.npcs + +import org.rsmod.api.player.dialogue.Dialogue +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpNpc1 +import org.rsmod.api.script.onOpNpc3 +import org.rsmod.api.script.onOpNpcU +import org.rsmod.content.skills.crafting.interfaces.TanningRecipe +import org.rsmod.content.skills.crafting.interfaces.tanningRecipes +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.entity.Npc +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** Thakkrad Sigmundson on Neitiznot */ +class ThakkradScript : PluginScript() { + + override fun ScriptContext.startup() { + if (!CraftingGamevals.exists(CraftingConstants.YAK_CURER)) { + return + } + onOpNpc1(CraftingConstants.YAK_CURER) { serviceMenu(it.npc, greet = true) } + onOpNpc3(CraftingConstants.YAK_CURER) { serviceMenu(it.npc, greet = false) } + onOpNpcU(CraftingConstants.YAK_CURER) { event -> + val row = yakRow() ?: return@onOpNpcU + if (event.objType.internalName == row.input) { + startDialogue(event.npc) { cureBranch(row) } + } + } + } + + private suspend fun ProtectedAccess.serviceMenu(npc: Npc, greet: Boolean) { + val row = yakRow() ?: return + startDialogue(npc) { + if (greet) { + chatNpc(happy, "What can I help you with?") + } + val cure = choice2( + "Cure my yak-hide, please.", true, + "Nothing, thanks.", false, + title = "What can I help you with?", + ) + if (cure) { + cureBranch(row) + } else { + declineService() + } + } + } + + private suspend fun Dialogue.declineService() { + chatPlayer(neutral, "Nothing, thanks.") + chatNpc(neutral, "See you later.") + chatNpc(neutral, "You won't find anyone else who can cure yak-hide.") + } + + /** Everything under "Cure my yak-hide, please." */ + private suspend fun Dialogue.cureBranch(row: TanningRecipe) { + val hide = row.input + val held = access.inv.count(hide) + + chatPlayer(happy, "Cure my yak-hide, please.") + chatNpc(neutral, "I will cure yak-hide for a fee of ${row.cost} gp per hide.") + + if (held == 0) { + chatNpc(neutral, "You have no yak-hide to cure.") + return + } + if (access.inv.count(CraftingConstants.COINS) < row.cost * held) { + chatNpc(neutral, "You don't have enough gold to pay me!") + return + } + + val choice = choice4( + "Cure all my hides.", CureChoice.All, + "Cure one hide.", CureChoice.One, + "Cure no hide.", CureChoice.None, + "Can you cure any other type of leather?", CureChoice.OtherLeather, + title = "How many hides do you want cured?", + ) + when (choice) { + CureChoice.All -> cure(row, held) + CureChoice.One -> cure(row, 1) + CureChoice.None -> chatNpc(neutral, "Bye.") + CureChoice.OtherLeather -> otherLeather() + } + } + + private suspend fun Dialogue.otherLeather() { + chatPlayer(quiz, "Can you cure any other type of leather?") + chatNpc(confused, "Other types of leather? Why would you need any other type of leather?") + chatPlayer(neutral, "I'll take that as a no then.") + } + + private suspend fun Dialogue.cure(row: TanningRecipe, amount: Int) { + if (!access.cureYakHides(row, amount)) { + chatNpc(neutral, "You don't have enough gold to pay me!") + return + } + chatNpc(happy, "There you go.") + } + + /** Coins and hide swap. Returns false (leaving the inventory untouched) if the player can no longer pay for [amount] hides. */ + private fun ProtectedAccess.cureYakHides(row: TanningRecipe, amount: Int): Boolean { + val hide = row.input + val cure = minOf(amount, inv.count(hide)) + if (cure <= 0) { + return false + } + + val totalCost = row.cost * cure + if (inv.count(CraftingConstants.COINS) < totalCost) { + return false + } + if (!invDel(inv, CraftingConstants.COINS, totalCost).success) { + return false + } + if (!invDel(inv, hide, cure).success) { + invAdd(inv, CraftingConstants.COINS, totalCost) + return false + } + invAdd(inv, row.output, cure) + return true + } + + /** The yak curing row, resolved on first use after cache load. */ + private fun yakRow(): TanningRecipe? = tanningRecipes.firstOrNull { it.output == "obj.yak_hide_cured" } + + /** Options for the how many hides menu. Only used internally. */ + private enum class CureChoice { All, One, None, OtherLeather } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/CraftingCommandsScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/CraftingCommandsScript.kt new file mode 100644 index 000000000..e9c7a5779 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/CraftingCommandsScript.kt @@ -0,0 +1,63 @@ +package org.rsmod.content.skills.crafting.scripts + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import dev.or2.central.account.Rights +import org.rsmod.api.invtx.invAdd +import org.rsmod.api.player.output.mes +import org.rsmod.api.script.onCommand +import org.rsmod.content.skills.crafting.CraftingRecipes +import org.rsmod.game.cheat.Cheat +import org.rsmod.game.entity.Player +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Debug commands for crafting. + * + * These live here rather than in the commands module so that module does not have to depend on + * crafting. `requiredRights` is set explicitly because the commands module's own `onCommand` helper + * applies it for every admin command, and that helper is internal to it. + */ +class CraftingCommandsScript : PluginScript() { + override fun ScriptContext.startup() { + onCommand("craftmat") { + requiredRights = Rights.ADMINISTRATOR + desc = "Spawn materials for a crafting recipe" + invalidArgs = + "Use as ::craftmat objDebugName [amount] (ex: ::craftmat red_dragonhide_body 6)" + cheat { craftMaterials() } + } + } + + private fun Cheat.craftMaterials() { + val typeName = args[0] + val crafts = args.getOrNull(1)?.toInt()?.coerceAtLeast(1) ?: 1 + + val output = "obj.$typeName" + val materials = CraftingRecipes.materialsFor(output, crafts, player) + if (materials == null) { + player.mes("No crafting recipe produces: $output") + return + } + + for (material in materials) { + // Only hand a tool over if they don't already have it. + if (material.tool && player.inv.contains(material.obj)) { + continue + } + player.spawnMaterial(material.obj, material.count) + } + player.mes("Spawned materials for `$typeName` x $crafts") + } + + /** Spawns [count] of [internal], uses noted items when it can't fit. */ + private fun Player.spawnMaterial(internal: String, count: Int) { + val type = ServerCacheManager.getItem(internal.asRSCM(RSCMType.OBJ)) ?: return + val slots = if (type.isStackable) 1 else count + val noted = type.certlink.takeIf { slots > inv.freeSpace() && type.canCert } + val spawn = noted?.let { ServerCacheManager.getItem(it)?.internalName } ?: internal + invAdd(inv, spawn, count, strict = false) + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/FacilityCraftingScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/FacilityCraftingScript.kt new file mode 100644 index 000000000..0775c4ae0 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/FacilityCraftingScript.kt @@ -0,0 +1,195 @@ +package org.rsmod.content.skills.crafting.scripts + +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.script.onOpLoc1 +import org.rsmod.api.script.onOpLoc2 +import org.rsmod.api.script.onOpLocCategoryU +import org.rsmod.api.script.onOpLocU +import org.rsmod.api.table.crafting.CraftingFacilitiesRow +import org.rsmod.content.skills.crafting.CraftingProduct +import org.rsmod.content.skills.crafting.CraftingSection +import org.rsmod.content.skills.crafting.beginCraft +import org.rsmod.content.skills.crafting.selectCraftingProduct +import org.rsmod.content.skills.crafting.toCraftingProduct +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.content.skills.crafting.util.CraftingGamevals +import org.rsmod.game.loc.BoundLocInfo +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class FacilityCraftingScript : PluginScript() { + private val rowsBySection: Map> by lazy { + CraftingFacilitiesRow.all().groupBy { it.section } + } + + private class Facility( + val section: CraftingSection, + val locs: List, + val materialOnLoc: Boolean = false, + val products: (loc: String) -> List, + ) + + /** The facility list, one entry per way of crafting at a loc. */ + private fun facilities(): List { + val spinning60 = spinningProducts(CraftingConstants.ANIM_SPINNING_60) + val spinning90 = spinningProducts(CraftingConstants.ANIM_SPINNING_90) + + return listOf( + Facility(CraftingSection.SPINNING, CraftingConstants.SPINNING_WHEELS, materialOnLoc = true) { loc -> + if (loc in CraftingConstants.SPINNING_WHEELS_60) spinning60 else spinning90 + }, + Facility(CraftingSection.WEAVING, CraftingConstants.LOOMS, materialOnLoc = true) { weavingProducts }, + Facility(CraftingSection.POTTERY_SHAPING, CraftingConstants.POTTERY_WHEELS) { shapingProducts }, + Facility(CraftingSection.POTTERY_FIRING, CraftingConstants.POTTERY_OVENS) { firingProducts }, + ) + } + + /** The spinning menu, listing its standard recipes first and material gated ones after. */ + private fun spinningProducts(anim: String): List = + products(CraftingSection.SPINNING) { row -> + row.toCraftingProduct( + anim = anim, + requiresMaterialsToShow = + row.output.first().internalName !in SPINNING_DEFAULT_RECIPES, + ) + } + .sortedBy { product -> + val index = SPINNING_DEFAULT_RECIPES.indexOf(product.output) + if (index == -1) SPINNING_DEFAULT_RECIPES.size else index + } + + /** The loom lists only what the player can currently weave, since every recipe is gated. */ + private val weavingProducts: List by lazy { + products(CraftingSection.WEAVING) { + it.toCraftingProduct(requiresMaterialsToShow = true) + } + } + + private val shapingProducts: List by lazy { + products(CraftingSection.POTTERY_SHAPING) { + it.toCraftingProduct(requiresMaterialsToShow = true) + } + } + + /** Shows three pottery defaults, with the other firing recipes requiring its unfired material before showing. */ + private val firingProducts: List by lazy { + products(CraftingSection.POTTERY_FIRING) { row -> + row.toCraftingProduct( + requiresMaterialsToShow = row.output.first().internalName !in OVEN_DEFAULT_RECIPES, + ) + } + .sortedBy { product -> + val index = OVEN_DEFAULT_RECIPES.indexOf(product.output) + if (index == -1) OVEN_DEFAULT_RECIPES.size else index + } + } + + private val smeltingProducts: List by lazy { + products(CraftingSection.GLASS_SMELTING) { it.toCraftingProduct() } + } + + /** Builds a section's products from the facilities table. */ + private fun products( + section: CraftingSection, + adapt: (CraftingFacilitiesRow) -> CraftingProduct, + ): List = rowsBySection[section.id].orEmpty().map(adapt) + + override fun ScriptContext.startup() { + for (facility in facilities()) { + for (loc in CraftingGamevals.filterResolvable(facility.locs)) { + val products = facility.products(loc) + onOpLoc1(loc) { selectCraftingProduct(facility.section, products, facility = it.loc) } + onOpLoc2(loc) { selectCraftingProduct(facility.section, products, facility = it.loc) } + if (facility.materialOnLoc) { + registerMaterialOnLoc(facility.section, loc, products) + } + registerProductOnLoc(loc, products) + } + } + + for (loc in CraftingGamevals.filterResolvable(CraftingConstants.POTTERY_OVENS)) { + for (product in firingProducts) { + val input = product.inputs.firstOrNull()?.internal ?: continue + if (!CraftingGamevals.exists(input)) continue + onOpLocU(loc, input) { openForInputOnOven(product, facility = it.loc) } + } + } + + for (loc in CraftingGamevals.filterResolvable(CraftingConstants.POTTERY_WHEELS)) { + objDialogueOnLoc(loc, CraftingConstants.CLAY, "This clay is too hard to craft.
You'll need to soften it with some water.") + onOpLocU(loc, CraftingConstants.SOFT_CLAY) { + selectCraftingProduct(CraftingSection.POTTERY_SHAPING, shapingProducts, facility = it.loc) + } + } + + for (item in listOf(CraftingConstants.BUCKET_OF_SAND, CraftingConstants.SODA_ASH)) { + onOpLocCategoryU(CraftingConstants.CATEGORY_FURNACE, item) { + selectCraftingProduct(CraftingSection.GLASS_SMELTING, smeltingProducts) + } + } + } + + /** Opens just the recipe a dragged material feeds, rather than the facility's whole list. */ + private fun ScriptContext.registerMaterialOnLoc( + section: CraftingSection, + loc: String, + products: List, + ) { + val byInput = products.groupBy { product -> product.inputs.firstOrNull()?.internal } + for ((input, matching) in byInput) { + if (input == null || !CraftingGamevals.exists(input)) { + continue + } + onOpLocU(loc, input) { selectCraftingProduct(section, matching, facility = it.loc) } + } + } + + /** Registers the dialogue a facility shows when one of its own products is used on it. */ + private fun ScriptContext.registerProductOnLoc(loc: String, products: List) { + val materials = products.flatMap { product -> product.inputs.map { it.internal } }.toSet() + val registered = mutableSetOf() + for (product in products) { + val output = product.output + val message = product.alreadyProcessedMessage ?: continue + if (output in materials || !CraftingGamevals.exists(output) || !registered.add(output)) { + continue + } + objDialogueOnLoc(loc, output, message) + } + } + + /** + * Registers the dialogue [loc] shows when [obj] is used on it. + * Covers both a facility rejecting its own product and a one-off such as dry clay. + */ + private fun ScriptContext.objDialogueOnLoc(loc: String, obj: String, message: String) { + onOpLocU(loc, obj) { objbox(obj, message) } + } + + /** Handles an unfired item dragged onto the oven, firing it outright when only one is held. */ + private suspend fun ProtectedAccess.openForInputOnOven( + product: CraftingProduct, + facility: BoundLocInfo, + ) { + val input = product.inputs.firstOrNull()?.internal ?: return + if (inv.count(input) == 1) { + beginCraft(product, amount = 1, facility = facility) + } else { + selectCraftingProduct(product.section, listOf(product), facility = facility) + } + } +} + +private val SPINNING_DEFAULT_RECIPES: List = listOf( + "obj.ball_of_wool", + "obj.bow_string", + "obj.rope", + "obj.xbows_crossbow_string", + "obj.magic_string", +) + +private val OVEN_DEFAULT_RECIPES: List = listOf( + "obj.pot_empty", + "obj.piedish", + "obj.bowl_empty", +) diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt new file mode 100644 index 000000000..718b24c4e --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt @@ -0,0 +1,20 @@ +package org.rsmod.content.skills.crafting.scripts + +import org.rsmod.api.table.crafting.CraftingHandRow +import org.rsmod.content.skills.crafting.CraftingMode +import org.rsmod.content.skills.crafting.CraftingProduct +import org.rsmod.content.skills.crafting.registerHeldCrafting +import org.rsmod.content.skills.crafting.toCraftingProduct +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** Held (inventory) crafting */ +class HeldCraftingScript : PluginScript() { + + override fun ScriptContext.startup() { + val products = CraftingHandRow.all() + .map { it.toCraftingProduct() } + .filter { it.section.mode != CraftingMode.SERVICE } + registerHeldCrafting(products) + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/JewelleryScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/JewelleryScript.kt new file mode 100644 index 000000000..1080e4d2d --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/JewelleryScript.kt @@ -0,0 +1,20 @@ +package org.rsmod.content.skills.crafting.scripts + +import org.rsmod.api.script.onOpLocCategoryU +import org.rsmod.content.skills.crafting.interfaces.openGoldCrafting +import org.rsmod.content.skills.crafting.interfaces.openSilverCrafting +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** A gold or silver bar used on a furnace opens that bar's crafting interface. */ +class JewelleryScript : PluginScript() { + override fun ScriptContext.startup() { + onOpLocCategoryU(CraftingConstants.CATEGORY_FURNACE, CraftingConstants.GOLD_BAR) { + openGoldCrafting() + } + onOpLocCategoryU(CraftingConstants.CATEGORY_FURNACE, CraftingConstants.SILVER_BAR) { + openSilverCrafting() + } + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/SandPitScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/SandPitScript.kt new file mode 100644 index 000000000..1a11628db --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/SandPitScript.kt @@ -0,0 +1,30 @@ +package org.rsmod.content.skills.crafting.scripts + +import org.rsmod.api.script.onOpContentMixedLocU +import org.rsmod.api.table.crafting.CraftingFacilitiesRow +import org.rsmod.content.skills.crafting.CraftingProduct +import org.rsmod.content.skills.crafting.CraftingSection +import org.rsmod.content.skills.crafting.selectCraftingProduct +import org.rsmod.content.skills.crafting.toCraftingProduct +import org.rsmod.content.skills.crafting.util.CraftingConstants +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** Sandpit, where an empty bucket used on a sandpit fills it with sand. */ +class SandPitScript : PluginScript() { + + private val products: List by lazy { + CraftingFacilitiesRow.all() + .filter { it.section == CraftingSection.SAND_PIT.id } + .map { it.toCraftingProduct() } + } + + override fun ScriptContext.startup() { + if (products.isEmpty()) { + return + } + onOpContentMixedLocU(CraftingConstants.CONTENT_SAND_PIT, CraftingConstants.BUCKET_EMPTY) { + selectCraftingProduct(CraftingSection.SAND_PIT, products, facility = it.loc) + } + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConfig.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConfig.kt new file mode 100644 index 000000000..70a6fcf42 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConfig.kt @@ -0,0 +1,6 @@ +package org.rsmod.content.skills.crafting.util + +object CraftingConfig { + /** When true, a crafting action with only one recipe skips the prompt and makes the maximum. */ + const val SKIP_SINGLE_RECIPE_PROMPT: Boolean = false +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt new file mode 100644 index 000000000..c1cf12ade --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt @@ -0,0 +1,190 @@ +package org.rsmod.content.skills.crafting.util + +object CraftingConstants { + + const val STAT_CRAFTING = "stat.crafting" + + /** Level cap for Crafting. Used for any level 99 crafting checks like the guild chest, crafting tutor dialog, cape teleport, etc. */ + const val MAX_CRAFTING_LEVEL = 99 + + /** Recipe xp is stored multiplied by this so the int columns can carry fractional xp. */ + const val FINE_XP_DIVISOR = 10 + + /** Shared production queue used by every crafting section. */ + const val QUEUE_CRAFTING_MAKE = "queue.crafting_make" + + /** Crafts per spool of thread. */ + const val THREAD_USES_PER_SPOOL = 5 + + const val NEEDLE = "obj.needle" + const val THREAD = "obj.thread" + const val CHISEL = "obj.chisel" + const val HAMMER = "obj.hammer" + const val KNIFE = "obj.knife" + const val GLASSBLOWING_PIPE = "obj.glassblowingpipe" + + const val IMCANDO_HAMMER = "obj.imcando_hammer" + const val IMCANDO_HAMMER_OFFHAND = "obj.imcando_hammer_offhand" + + const val CLOCKWORK = "obj.poh_clockwork_mechanism" + + const val ANIM_BIRDHOUSE = "seq.birdhouse_make" + const val ANIM_BIRDHOUSE_IMCANDO = "seq.birdhouse_make_imcando_hammer" + + const val COINS = "obj.coins" + + const val GOLD_BAR = "obj.gold_bar" + const val SILVER_BAR = "obj.silver_bar" + + const val CLAY = "obj.clay" + const val SOFT_CLAY = "obj.softclay" + const val BUCKET_EMPTY = "obj.bucket_empty" + const val SODA_ASH = "obj.soda_ash" + const val BUCKET_OF_SAND = "obj.bucket_sand" + + + const val COSTUME_NEEDLE = "obj.costumeneedle" + + + const val CATEGORY_FURNACE = "category.furnace" + + val SPINNING_WHEELS: List = listOf( + "loc.viking_spinningwheel", + "loc.elf_village_spinning_wheel", + "loc.spinningwheel", + "loc.contact_spinning_wheel", + "loc.iznot_spinning_wheel", + "loc.kr_spinningwheel", + "loc.murder_qip_spinning_wheel", + "loc.fossil_spinning_wheel_built", + "loc.sw_spinningwheel_fixed", + "loc.spinningwheel_quetzacali", + "loc.spinningwheel_2", + "loc.amenity_spinning_wheel_built", + ) + + /** Loom locs. */ + val LOOMS: List = listOf( + "loc.loom", + "loc.regicide_loom", + "loc.fossil_loom_built", + "loc.amenity_loom_built", + ) + + /** Potter's wheel locs. */ + val POTTERY_WHEELS: List = listOf( //Note: Category 377 are pottery wheels, but includes unbuilt and broken + "loc.viking_potterywheel", + "loc.potterywheel", + "loc.contact_potterywheel", + "loc.darkm_poor_potterywheel", + "loc.sw_potterywheel_fixed", + "loc.potterywheel_2", + "loc.amenity_potterywheel_built", + ) + + /** Pottery oven locs. */ + val POTTERY_OVENS: List = listOf( //Note: No category data on the ovens + "loc.potteryoven", + "loc.viking_potteryoven", + "loc.amenity_potteryoven_built", + "loc.fai_barbarian_pottery_oven", + "loc.darkm_poor_pottery_oven", + ) + + /** Thakkrad Sigmundson */ + const val YAK_CURER = "npc.fris_r_engineer" + + const val CRAFTING_TUTOR = "npc.aide_tutor_crafting" + + const val ANIM_SPINNING_60 = "seq.human_spinningwheel_60" + const val ANIM_SPINNING_90 = "seq.human_spinningwheel_90" + const val ANIM_SPINNING = ANIM_SPINNING_90 + + /** Wheel locs that use the 60-frame spinning animation instead of the default 90-frame one. */ + val SPINNING_WHEELS_60: Set = setOf( + //Currently empty + ) + + const val LOC_ANIM_SPINNING = "seq.spinningwheel" + const val SOUND_SPINNING = "synth.spinning" + + const val ANIM_WEAVING = "seq.farming_useloom" + const val LOC_ANIM_WEAVING = "seq.loom" + const val SOUND_WEAVING = "synth.loom_weave" + + const val ANIM_POTTERY_WHEEL = "seq.human_potterywheel" + const val LOC_ANIM_POTTERY_WHEEL = "seq.potterywheel" + const val SOUND_POTTERY_WHEEL = "synth.crafting_pottery_wheel_craft" + const val ANIM_POTTERY_OVEN = "seq.potteryoven_quick" + + const val ANIM_LEATHER_CRAFT = "seq.human_leather_crafting" + const val SOUND_LEATHER_CRAFT = "synth.stiching" + + const val ANIM_PHEASANT_COSTUME = "seq.human_pheasant_feathers_crafting" + + const val ANIM_GEM_CUTTING = "seq.human_gem_cutting" + const val SOUND_GEM_CUTTING = "synth.chisel" + const val SOUND_GEM_CRUSH = "synth.smash_gem" + const val ANIM_AMETHYST_CUT = "seq.human_amethystcutting" + const val ANIM_SNAIL_SHELL_CUT = "seq.human_snailshellcutting" + const val ANIM_KNIFE_CUTTING = "seq.human_cutting_knife" + const val ANIM_LIMESTONE_CUT = "seq.human_limestonecutting" + + const val ANIM_GLASSBLOWING = "seq.human_glassblowing" + const val SOUND_GLASSBLOWING = "synth.glassblowing" + + const val ANIM_FURNACE = "seq.human_furnace" + const val SOUND_FURNACE = "synth.furnace" + + const val ANIM_BATTLESTAFF = "seq.human_battlestaff_crafting" + const val SOUND_BATTLESTAFF_ATTACH = "synth.attach_orb" + + const val SOUND_AMULET_STRINGING = "synth.stringing" + + /** Player animation for filling a bucket at a sand pit. */ + const val ANIM_SAND_PIT = "seq.human_fillbucket_sandpit" + /** Sound played on each bucket fill. */ + const val SOUND_SAND_BUCKET = "synth.sand_bucket" + /** Content group shared by every sandpit loc. */ + const val CONTENT_SAND_PIT = "content.sandpit" + + /** The crafting cape and its trimmed variant. */ + val CRAFTING_SKILLCAPES: Set = + setOf("obj.skillcape_crafting", "obj.skillcape_crafting_trimmed") + + /** Aprons that get a player through the guild door. */ + val GUILD_APRONS: Set = setOf("obj.brown_apron", "obj.golden_apron") + + /** Max capes, which stand in for the crafting cape at the guild door. */ + val MAX_SKILLCAPES: Set = + setOf( + "obj.skillcape_max", + "obj.skillcape_max_firecape", + "obj.skillcape_max_saradomin", + "obj.skillcape_max_zamorak", + "obj.skillcape_max_guthix", + "obj.skillcape_max_anma", + "obj.skillcape_max_worn", + "obj.skillcape_max_ardy", + "obj.skillcape_max_infernalcape", + "obj.skillcape_max_saradomin2", + "obj.skillcape_max_zamorak2", + "obj.skillcape_max_guthix2", + "obj.skillcape_max_assembler", + "obj.skillcape_max_infernalcape_trouver", + "obj.skillcape_max_firecape_trouver", + "obj.skillcape_max_assembler_trouver", + "obj.skillcape_max_saradomin2_trouver", + "obj.skillcape_max_zamorak2_trouver", + "obj.skillcape_max_guthix2_trouver", + "obj.skillcape_max_mythical", + "obj.skillcape_max_assembler_masori", + "obj.skillcape_max_assembler_masori_trouver", + "obj.skillcape_max_dizanas", + "obj.skillcape_max_dizanas_trouver", + ) + + /** Selected make-quantity, shared by the `skillmain` quantity column across skill interfaces. */ + const val VARP_MAKEX_CRAFTING = "varp.makexcrafting" + +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGamevals.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGamevals.kt new file mode 100644 index 000000000..ab31d2359 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGamevals.kt @@ -0,0 +1,72 @@ +package org.rsmod.content.skills.crafting.util + +import com.github.michaelbull.logging.InlineLogger +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType + +/** Defensive resolution for crafting gameval strings, where a raw RSCM lookup would throw. */ +object CraftingGamevals { + private val logger = InlineLogger() + private val resolvable = HashMap() + private val warned = HashSet() + + /** The RSCM type a gameval prefix names. */ + private fun typeOf(gameval: String): RSCMType? = + when (gameval.substringBefore('.', "")) { + "seq" -> RSCMType.SEQ + "synth" -> RSCMType.SYNTH + "spotanim" -> RSCMType.SPOTANIM + "loc" -> RSCMType.LOC + "npc" -> RSCMType.NPC + "obj" -> RSCMType.OBJ + "varbit" -> RSCMType.VARBIT + "interface" -> RSCMType.INTERFACE + "component" -> RSCMType.COMPONENT + else -> null + } + + /** True if [gameval] resolves against the cache. Never throws. */ + fun exists(gameval: String): Boolean = + resolvable.getOrPut(gameval) { + val type = typeOf(gameval) ?: return@getOrPut false + try { + gameval.asRSCM(type) + true + } catch (_: Exception) { + false + } + } + + /** Returns [gameval] if it resolves, else null, logging the miss once. Use for anim and sound. */ + fun optional(gameval: String?): String? { + if (gameval == null) return null + if (exists(gameval)) return gameval + warnOnce(gameval) + return null + } + + /** Keeps only resolvable entries, logging any misses once. Use for loc and npc trigger lists. */ + fun filterResolvable(gamevals: List): List = + gamevals.filter { entry -> + exists(entry).also { if (!it) warnOnce(entry) } + } + + /** Resolves an obj gameval to its numeric id, or null when it does not resolve. */ + fun objOrNull(gameval: String): Int? = + if (exists(gameval)) { + try { + gameval.asRSCM(RSCMType.OBJ) + } catch (_: Exception) { + null + } + } else { + null + } + + /** Logs a missing gameval the first time it is seen. */ + private fun warnOnce(gameval: String) { + if (warned.add(gameval)) { + logger.warn { "Crafting: gameval '$gameval' did not resolve; skipping it." } + } + } +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGates.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGates.kt new file mode 100644 index 000000000..7db7418d0 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingGates.kt @@ -0,0 +1,44 @@ +package org.rsmod.content.skills.crafting.util + +import dev.openrune.tables.skills.QuestReq +import dev.openrune.tables.skills.VarbitCompare +import org.rsmod.content.quest.manager.QuestRequirement +import org.rsmod.content.quest.manager.QuestRequirements +import org.rsmod.content.skills.crafting.CraftingProduct +import org.rsmod.game.entity.Player + +data class CraftingQuestReq(val quest: String, val requirement: QuestRequirement) + +data class CraftingVarbitReq(val varbit: String, val compare: VarbitCompare, val value: Int) + +/** Reads a quest gate/requirement off a recipe row, defaulting to requiring completion. */ +fun craftingQuestReq(quest: String?, requirement: Int?): CraftingQuestReq? { + if (quest.isNullOrBlank()) { + return null + } + val parsed = + when (QuestReq.of(requirement) ?: QuestReq.Completed) { + QuestReq.Completed -> QuestRequirement.Completed + QuestReq.InProgress -> QuestRequirement.InProgress + QuestReq.NotCompleted -> QuestRequirement.NotCompleted + } + return CraftingQuestReq(quest, parsed) +} + +/** Reads a varbit gate off a recipe row, defaulting to at least the given value. */ +fun craftingVarbitReq(varbit: String?, compare: Int?, value: Int?): CraftingVarbitReq? { + if (varbit.isNullOrBlank()) { + return null + } + return CraftingVarbitReq(varbit, VarbitCompare.of(compare) ?: VarbitCompare.GTE, value ?: 1) +} + +/** Whether every gate/requirement on [product] passes. An unlock varbit missing from the cache counts as passed. */ +fun Player.meetsUnlocks(product: CraftingProduct): Boolean = + product.questReqs.all { QuestRequirements.satisfies(this, it.quest, it.requirement) } && + product.varbitReqs.all { req -> + !CraftingGamevals.exists(req.varbit) || req.compare.passes(vars[req.varbit], req.value) + } + +/** Whether the quest manager considers [quest] complete for this player. */ +fun Player.hasCompletedQuest(quest: String): Boolean = QuestRequirements.hasCompleted(this, quest) diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingTools.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingTools.kt new file mode 100644 index 000000000..6edf2f070 --- /dev/null +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingTools.kt @@ -0,0 +1,47 @@ +package org.rsmod.content.skills.crafting.util + +import org.rsmod.api.player.protect.ProtectedAccess + +/** The equivalency mapping for tools */ +private val TOOL_EQUIVALENTS: Map> = mapOf( + CraftingConstants.HAMMER to listOf( + CraftingConstants.HAMMER, + CraftingConstants.IMCANDO_HAMMER, + CraftingConstants.IMCANDO_HAMMER_OFFHAND, + ), + CraftingConstants.NEEDLE to listOf( + CraftingConstants.NEEDLE, + CraftingConstants.COSTUME_NEEDLE, + ), +) + +/** Tools that also satisfy a requirement from a worn slot (any hand). */ +private val WORN_TOOLS: Set = setOf( + CraftingConstants.IMCANDO_HAMMER, + CraftingConstants.IMCANDO_HAMMER_OFFHAND, +) + +/** The objs that satisfy [tool]. This includes the tool itself. */ +fun toolEquivalents(tool: String): List = TOOL_EQUIVALENTS[tool] ?: listOf(tool) + +/** Whether the player has [tool] or any of its equivalents, held or worn. */ +fun ProtectedAccess.hasCraftingTool(tool: String): Boolean = toolEquivalents(tool).any { holds(it) } + +/** True if the player is holding an imcando hammer. */ +fun ProtectedAccess.holdsImcandoHammer(): Boolean = IMCANDO_HAMMERS.any { holds(it) } + +fun ProtectedAccess.holdsCostumeNeedle(): Boolean = inv.contains(CraftingConstants.COSTUME_NEEDLE) + +/** Whether [tool] is in the inventory, or worn when it is one that counts from a hand slot. */ +private fun ProtectedAccess.holds(tool: String): Boolean { + if (inv.contains(tool)) { + return true + } + val wearable = tool in WORN_TOOLS + return wearable && tool in player.worn +} + +private val IMCANDO_HAMMERS = listOf( + CraftingConstants.IMCANDO_HAMMER, + CraftingConstants.IMCANDO_HAMMER_OFFHAND, +) diff --git a/content/skills/crafting/src/main/resources/gamevals.toml b/content/skills/crafting/src/main/resources/gamevals.toml new file mode 100644 index 000000000..1c01521e0 --- /dev/null +++ b/content/skills/crafting/src/main/resources/gamevals.toml @@ -0,0 +1,267 @@ +# All crafting dbtable/dbrow ids live in the 63000-64000 block + +[gamevals.queue] +# Shared worker queue used by every crafting section (see CraftingWorker). +crafting_make = 57 +crafting_cape_teleport = 60 + +[gamevals.synth] +crafting_pottery_wheel_craft = 2588 +glassblowing = 2724 + +sand_bucket = 2584 +attach_orb = 2585 +chisel = 2586 +loom_weave = 2587 +smash_gem = 2589 +spinning = 2590 +stiching = 2591 +stringing = 2593 + +[gamevals.content] +sandpit = 54 + +[gamevals.dbtable] +crafting_facilities = 63000 +crafting_hand = 63001 +crafting_silver = 63011 +crafting_gold = 63012 + +[gamevals.dbrow] +crafting_spin_ball_of_wool =63100 +crafting_spin_bow_string =63101 +crafting_spin_crossbow_string =63102 +crafting_spin_crossbow_string_roots =63103 +crafting_spin_magic_string =63104 +crafting_spin_rope =63105 +crafting_spin_linen_yarn =63106 +crafting_spin_hemp_yarn =63107 +crafting_spin_cotton_yarn =63108 +crafting_weave_strip_of_cloth =63109 +crafting_weave_bolt_of_linen =63110 +crafting_weave_empty_sack =63111 +crafting_weave_drift_net =63112 +crafting_weave_basket =63113 +crafting_weave_bolt_of_canvas =63114 +crafting_weave_bolt_of_cotton =63115 +crafting_shape_pot =63116 +crafting_shape_cup =63117 +crafting_shape_pie_dish =63118 +crafting_shape_bowl =63119 +crafting_shape_plant_pot =63120 +crafting_shape_pot_lid =63121 +crafting_fire_pot =63122 +crafting_fire_cup =63123 +crafting_fire_pie_dish =63124 +crafting_fire_bowl =63125 +crafting_fire_plant_pot =63126 +crafting_fire_pot_lid =63127 +crafting_leather_gloves =63128 +crafting_leather_boots =63129 +crafting_leather_cowl =63130 +crafting_leather_vambraces =63131 +crafting_leather_body =63132 +crafting_leather_chaps =63133 +crafting_hardleather_body =63134 +crafting_coif =63135 +crafting_studded_body =63136 +crafting_studded_chaps =63137 +crafting_spiky_vambraces =63138 +crafting_green_dhide_vambraces =63139 +crafting_green_dhide_chaps =63140 +crafting_green_dhide_body =63141 +crafting_blue_dhide_vambraces =63142 +crafting_blue_dhide_chaps =63143 +crafting_blue_dhide_body =63144 +crafting_red_dhide_vambraces =63145 +crafting_red_dhide_chaps =63146 +crafting_red_dhide_body =63147 +crafting_black_dhide_vambraces =63148 +crafting_black_dhide_chaps =63149 +crafting_black_dhide_body =63150 +crafting_snakeskin_boots =63151 +crafting_snakeskin_vambraces =63152 +crafting_snakeskin_bandana =63153 +crafting_snakeskin_chaps =63154 +crafting_snakeskin_body =63155 +crafting_yak_legs =63156 +crafting_yak_top =63157 +crafting_xerician_hat =63158 +crafting_xerician_robe =63159 +crafting_xerician_top =63160 +crafting_splitbark_gauntlets =63161 +crafting_splitbark_boots =63162 +crafting_splitbark_helm =63163 +crafting_splitbark_legs =63164 +crafting_splitbark_body =63165 +crafting_hueycoatl_vambraces =63166 +crafting_hueycoatl_coif =63167 +crafting_hueycoatl_chaps =63168 +crafting_hueycoatl_body =63169 +crafting_mixed_hide_cape =63170 +crafting_mixed_hide_boots =63171 +crafting_mixed_hide_legs =63172 +crafting_mixed_hide_top =63173 +crafting_hard_leather_shield =63174 +crafting_snakeskin_shield =63175 +crafting_green_dhide_shield =63176 +crafting_blue_dhide_shield =63177 +crafting_red_dhide_shield =63178 +crafting_black_dhide_shield =63179 +crafting_broodoo_shield_blue =63180 +crafting_broodoo_shield_green =63181 +crafting_broodoo_shield_orange =63182 +crafting_crab_helmet =63183 +crafting_crab_claw =63184 +crafting_cut_opal =63185 +crafting_cut_jade =63186 +crafting_cut_red_topaz =63187 +crafting_cut_sapphire =63188 +crafting_cut_emerald =63189 +crafting_cut_ruby =63190 +crafting_cut_diamond =63191 +crafting_cut_dragonstone =63192 +crafting_cut_onyx =63193 +crafting_cut_zenyte =63194 +crafting_amethyst_bolt_tips =63195 +crafting_amethyst_arrowtips =63196 +crafting_amethyst_javelin_heads =63197 +crafting_amethyst_dart_tips =63198 +crafting_molten_glass =63199 +crafting_glass_beer_glass =63200 +crafting_glass_candle_lantern =63201 +crafting_glass_oil_lamp =63202 +crafting_glass_vial =63203 +crafting_glass_fishbowl =63204 +crafting_glass_unpowered_orb =63205 +crafting_glass_lantern_lens =63206 +crafting_glass_light_orb =63207 +crafting_unstrung_symbol =63208 +crafting_unstrung_emblem =63209 +crafting_silver_sickle =63210 +crafting_silver_bolts =63211 +crafting_tiara =63212 +crafting_gold_ring =63213 +crafting_gold_necklace =63214 +crafting_gold_bracelet =63215 +crafting_gold_amulet =63216 +crafting_water_battlestaff =63217 +crafting_earth_battlestaff =63218 +crafting_fire_battlestaff =63219 +crafting_air_battlestaff =63220 +crafting_tan_soft_leather =63221 +crafting_tan_hard_leather =63222 +crafting_tan_snakeskin =63223 +crafting_tan_snakeskin_swamp =63224 +crafting_tan_green_dhide =63225 +crafting_tan_blue_dhide =63226 +crafting_tan_red_dhide =63227 +crafting_tan_black_dhide =63228 +crafting_cure_yak_hide =63229 +crafting_snelm_red_pointed =63230 +crafting_snelm_red_round =63231 +crafting_snelm_bark =63232 +crafting_snelm_blue_pointed =63233 +crafting_snelm_blue_round =63234 +crafting_snelm_myre_pointed =63235 +crafting_snelm_myre_round =63236 +crafting_snelm_ochre_pointed =63237 +crafting_snelm_ochre_round =63238 +crafting_opal_ring =63239 +crafting_opal_necklace =63240 +crafting_opal_bracelet =63241 +crafting_opal_amulet =63242 +crafting_jade_ring =63243 +crafting_jade_necklace =63244 +crafting_jade_bracelet =63245 +crafting_jade_amulet =63246 +crafting_topaz_ring =63247 +crafting_topaz_necklace =63248 +crafting_topaz_bracelet =63249 +crafting_topaz_amulet =63250 +crafting_sapphire_ring =63251 +crafting_sapphire_necklace =63252 +crafting_sapphire_bracelet =63253 +crafting_sapphire_amulet =63254 +crafting_emerald_ring =63255 +crafting_emerald_necklace =63256 +crafting_emerald_bracelet =63257 +crafting_emerald_amulet =63258 +crafting_ruby_ring =63259 +crafting_ruby_necklace =63260 +crafting_ruby_bracelet =63261 +crafting_ruby_amulet =63262 +crafting_diamond_ring =63263 +crafting_diamond_necklace =63264 +crafting_diamond_bracelet =63265 +crafting_diamond_amulet =63266 +crafting_dragonstone_ring =63267 +crafting_dragonstone_necklace =63268 +crafting_dragonstone_bracelet =63269 +crafting_dragonstone_amulet =63270 +crafting_onyx_ring =63271 +crafting_onyx_necklace =63272 +crafting_onyx_bracelet =63273 +crafting_onyx_amulet =63274 +crafting_zenyte_ring =63275 +crafting_zenyte_necklace =63276 +crafting_zenyte_bracelet =63277 +crafting_zenyte_amulet =63278 +crafting_limestone_brick =63279 +crafting_string_gold_amulet =63280 +crafting_string_sapphire_amulet =63281 +crafting_string_emerald_amulet =63282 +crafting_string_ruby_amulet =63283 +crafting_string_diamond_amulet =63284 +crafting_string_dragonstone_amulet =63285 +crafting_string_onyx_amulet =63286 +crafting_string_zenyte_amulet =63287 +crafting_string_opal_amulet =63288 +crafting_string_jade_amulet =63289 +crafting_string_topaz_amulet =63290 +crafting_string_emblem =63291 +crafting_string_symbol =63292 +crafting_slayer_ring =63293 +crafting_slayer_ring_eternal =63294 +crafting_conductor =63295 +crafting_silvthrill_rod =63296 +crafting_demonic_sigil =63297 +crafting_slayer_helm =63300 +crafting_noxious_halberd =63301 +crafting_toxic_staff_of_the_dead =63302 +crafting_trident_of_the_swamp =63303 +crafting_bone_staff =63304 +crafting_accursed_sceptre =63305 +crafting_strung_rabbit_foot =63306 +crafting_birdhouse_normal =63308 +crafting_birdhouse_oak =63309 +crafting_birdhouse_willow =63310 +crafting_birdhouse_teak =63311 +crafting_birdhouse_maple =63312 +crafting_birdhouse_mahogany =63313 +crafting_birdhouse_yew =63314 +crafting_birdhouse_magic =63315 +crafting_birdhouse_redwood =63316 +crafting_soft_clay_bucket_water =63317 +crafting_soft_clay_jug_water =63318 +crafting_soft_clay_bowl_water =63319 +crafting_soft_clay_cup_water =63320 +crafting_fill_bucket_sand =63322 +crafting_serpentine_helm =63323 +crafting_pheasant_boots =63324 +crafting_pheasant_hat =63325 +crafting_pheasant_legs =63326 +crafting_pheasant_cape =63327 +crafting_break_armadyl_chestplate =63328 +crafting_break_armadyl_skirt =63329 +crafting_break_armadyl_helmet =63330 +crafting_fortify_masori_body =63331 +crafting_fortify_masori_chaps =63332 +crafting_fortify_masori_mask =63333 +crafting_dramen_staff =63334 +crafting_sinew =63335 +crafting_light_orb =63336 +crafting_slayer_helm_goggles =63337 +crafting_amulet_of_rancour =63338 +crafting_confliction_gauntlets =63339 +crafting_necklace_of_rupture =63340 diff --git a/content/skills/smithing/build.gradle.kts b/content/skills/smithing/build.gradle.kts index 1f864044f..1149c97cb 100644 --- a/content/skills/smithing/build.gradle.kts +++ b/content/skills/smithing/build.gradle.kts @@ -8,4 +8,5 @@ dependencies { implementation(projects.api.pluginCommons) implementation(projects.api.attr) implementation(projects.content.skills.utils) + implementation(projects.content.skills.crafting) } diff --git a/content/skills/smithing/src/main/kotlin/org/rsmod/content/skills/smithing/smelting/SmeltingScript.kt b/content/skills/smithing/src/main/kotlin/org/rsmod/content/skills/smithing/smelting/SmeltingScript.kt index e1265bcde..4cab91afa 100644 --- a/content/skills/smithing/src/main/kotlin/org/rsmod/content/skills/smithing/smelting/SmeltingScript.kt +++ b/content/skills/smithing/src/main/kotlin/org/rsmod/content/skills/smithing/smelting/SmeltingScript.kt @@ -11,6 +11,10 @@ import org.rsmod.content.skills.Material import org.rsmod.content.skills.SkillMultiConfig import org.rsmod.content.skills.SkillMultiEntry import org.rsmod.content.skills.SkillingActionType +import org.rsmod.content.skills.crafting.interfaces.hasGoldCraftingBars +import org.rsmod.content.skills.crafting.interfaces.hasSilverCraftingBars +import org.rsmod.content.skills.crafting.interfaces.openGoldCrafting +import org.rsmod.content.skills.crafting.interfaces.openSilverCrafting import org.rsmod.content.skills.openSkillMulti import org.rsmod.content.skills.smithing.hasCannonballFurnaceMould import org.rsmod.content.skills.smithing.openCannonballFurnaceMenu @@ -42,6 +46,10 @@ class SmeltingScript @Inject constructor(private val xpMods: XpModifiers, ) : Pl openStandardSmeltMenu(locInternal, coords) } else if (hasCannonballFurnaceMould()) { openCannonballFurnaceMenu(locInternal) + } else if (hasGoldCraftingBars()) { + openGoldCrafting() + } else if (hasSilverCraftingBars()) { + openSilverCrafting() } } diff --git a/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt b/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt index 3eb4da0a5..8260e777c 100644 --- a/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt +++ b/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt @@ -30,6 +30,7 @@ import dev.openrune.tables.SettingConfigs import dev.openrune.tables.ShopCurrencyTable import dev.openrune.tables.StatComponents import dev.openrune.tables.skills.Cooking +import dev.openrune.tables.skills.Crafting import dev.openrune.tables.skills.Firemaking import dev.openrune.tables.skills.Herblore import dev.openrune.tables.skills.Mining @@ -87,6 +88,10 @@ fun tablesToPack() = listOf( Herblore.barbarianMixes(), Herblore.swampTar(), Herblore.crushing(), + Crafting.facilities(), + Crafting.hand(), + Crafting.silver(), + Crafting.gold(), Smithing.bars(), Smithing.cannonBalls(), Smithing.dragonForge(), diff --git a/or-cache/src/main/kotlin/dev/openrune/codegen/TableGenerater.kt b/or-cache/src/main/kotlin/dev/openrune/codegen/TableGenerater.kt index 479bf6868..be3ae31e7 100644 --- a/or-cache/src/main/kotlin/dev/openrune/codegen/TableGenerater.kt +++ b/or-cache/src/main/kotlin/dev/openrune/codegen/TableGenerater.kt @@ -264,6 +264,7 @@ fun startGeneration( val table = el.elementAs() ?: continue val samples = sampleRowsForTable(table.id, rows) val meta = mergeColumnMetadata(table, samples) + val declared = dbtables[table.id]?.columns val maxLen = table.columns.associate { c -> c.name to (samples.maxOfOrNull { it.valuesAt(c.id)?.size ?: 0 } ?: 0) @@ -271,6 +272,12 @@ fun startGeneration( val columns = table.columns.map { c -> val m = meta.getValue(c.name) + // A column no row fills has no sampled types, so fall back to the types the table declared. + // Without this the column generates no property at all. + if (m.slotTypes.isEmpty()) { + declared?.get(c.id)?.types?.forEachIndexed { i, t -> m.slotTypes[i] = t } + m.optional = true + } val slotList = m.slotTypes.toSortedMap().values.toList() TableColumn( name = "dbcol.${table.name}:${c.name}", diff --git a/or-cache/src/main/kotlin/dev/openrune/tables/skills/Crafting.kt b/or-cache/src/main/kotlin/dev/openrune/tables/skills/Crafting.kt new file mode 100644 index 000000000..246e8059d --- /dev/null +++ b/or-cache/src/main/kotlin/dev/openrune/tables/skills/Crafting.kt @@ -0,0 +1,2598 @@ +package dev.openrune.tables.skills + +import dev.openrune.definition.constants.ConstantProvider +import dev.openrune.definition.dbtables.DBTableBuilder +import dev.openrune.definition.util.VarType +import dev.openrune.tables.production.ProductionColumns +import dev.openrune.tables.production.ProductionTableRowScope +import dev.openrune.tables.production.ProductionTableScope +import dev.openrune.tables.production.productionTable + +/** + * Columns that aren't specified defer to the section default. Every crafting table shares one + * column layout, so a recipe uses the same columns regardless of the section or table it belongs to. + * + * Some columns accept more than one value: + * - `anim`/`spotanim`: played one per craft, in order, cycling back to the first once the list runs + * out. + * - `ticks`: per-craft timings for a batch. `1, 3, 2` makes the first craft take a tick, the second + * three, and the third onwards two - the last entry carries the rest of the batch. + * - `tool`: every obj listed must be held or worn. + * - `confirm_title`: each entry is one prompt, asked in order, and all of them must be confirmed. + * Second and later prompts reverse their options. + * - `quest_req`: a quest key followed by a [QuestReq] id. Repeat the pair to require more than one + * quest. Resolved through the quest manager, so the server's quest-requirement mode applies. + * - `unlock_varbit`: a varbit, a [VarbitCompare] id, and the value to compare against. Use it for + * non-quest unlocks such as Slayer rewards. + * + * A recipe whose `quest_req` and `unlock_varbit` entries don't all pass is hidden, so two recipes + * for one output can differ by gate alone. This is how the slayer helmet gains its reinforced + * goggles after A Porcine of Interest. The `locked_message` column is used when a requirement is missing. + * + * Optional columns: + * - `success_low`/`success_high`: chance numerators out of 256 at levels 1 and 99 (see MathSkillUtiils.computeSkillingSuccess. + * Higher above 256 means success is guaranteed before 99). When omitted, the craft can never fail. + * - `fail_xp`/`fail_item`: experience (tenths) and obj produced on a failed roll. + * - `anim`/`spotanim`: The (spot) animation gameval, played on the player. A facility's own + * animation is unaffected. + * - `ticks`: Craft time in ticks. A 0 means no pacing at all - a held recipe then crafts once per + * click like limestone, and any other kind crafts the whole requested amount in one cycle. + * - `triggers`: for combines, the inputs that may be clicked on one another to start the craft. Omit + * it to make every input a trigger and list them only to exclude some (the bone staff's chaos + * runes), which stay consumed but inert to clicks. + * - `xp_extra`: Experience paid out to any second skill (see noxious halberd for an example). + * - `cost`: coin cost per item (tanning only). + * - `tool`: an obj that must be held but is never consumed, added to the section's tool list. The + * silver/gold moulds are declared here. + * - `confirm_title`: shows a yes/no prompt titled with it before the craft runs. Its presence is + * what turns the confirmation flow on. + * - `confirm_warning`: an optional message box shown ahead of the first `confirm_title` prompt. + * - `result_dialogue`: an item box shown on a successful craft in place of the section's success + * line. Works with or without a confirmation. + * - `sound`/`message`/`action_name`: Recipe overrides of the section's craft sound, success message, + * and level-gate phrase. `{input}`/`{output}` will interpolate. + * + */ +object Crafting { + + /** Section the recipe belongs to. */ + const val COL_SECTION = 7 + + const val COL_SUCCESS_LOW = 8 + const val COL_SUCCESS_HIGH = 9 + + const val COL_TICKS = 10 + + const val COL_FAIL_XP = 11 + const val COL_FAIL_ITEM = 12 + const val COL_ANIM = 13 + const val COL_SPOTANIM = 14 + const val COL_TRIGGERS = 15 + const val COL_XP_EXTRA = 16 + + /** Coin cost per item (tanning). */ + const val COL_COST = 17 + + /** An extra tool the recipe requires beyond any listed in it's section. */ + const val COL_TOOL = 18 + + const val COL_SOUND = 19 + + /** Success message override. {output} and {input} get interpolated in. */ + const val COL_SPAM_MESSAGE = 20 + + const val COL_ACTION_NAME = 21 + + const val COL_CONFIRM_TITLE = 22 + const val COL_CONFIRM_WARNING = 23 + const val COL_RESULT_DIALOGUE = 24 + + const val COL_QUEST_REQ = 25 + const val COL_UNLOCK_VARBIT = 26 + const val COL_LOCKED_MESSAGE = 27 + + + fun facilities() = craftingTable("dbtable.crafting_facilities") { + section("Spinning", category = "Spin") { + row("dbrow.crafting_spin_ball_of_wool") { + production { + input("obj.wool") + statReq("stat.crafting", 1) + xp(25) + output("obj.ball_of_wool") + } + } + row("dbrow.crafting_spin_bow_string") { + production { + input("obj.flax") + statReq("stat.crafting", 10) + xp(150) + output("obj.bow_string") + } + } + row("dbrow.crafting_spin_crossbow_string") { + production { + input("obj.xbows_sinew") + statReq("stat.crafting", 10) + xp(150) + output("obj.xbows_crossbow_string") + } + } + row("dbrow.crafting_spin_crossbow_string_roots") { + production { + input("obj.oak_roots") + statReq("stat.crafting", 10) + xp(150) + output("obj.xbows_crossbow_string") + } + } + row("dbrow.crafting_spin_magic_string") { + production { + input("obj.magic_roots") + statReq("stat.crafting", 19) + xp(300) + output("obj.magic_string") + } + } + row("dbrow.crafting_spin_rope") { + production { + input("obj.yak_hair") + statReq("stat.crafting", 30) + xp(250) + output("obj.rope") + } + } + row("dbrow.crafting_spin_linen_yarn") { + production { + input("obj.flax") + statReq("stat.crafting", 12) + xp(160) + output("obj.linen_yarn") + } + } + row("dbrow.crafting_spin_hemp_yarn") { + production { + input("obj.hemp") + statReq("stat.crafting", 39) + xp(600) + output("obj.hemp_yarn") + } + } + row("dbrow.crafting_spin_cotton_yarn") { + production { + input("obj.cotton_boll") + statReq("stat.crafting", 73) + xp(1050) + output("obj.cotton_yarn") + } + } + } + section("Weaving", category = "Weave") { + row("dbrow.crafting_weave_strip_of_cloth") { + production { + input("obj.ball_of_wool", 4) + statReq("stat.crafting", 10) + xp(120) + output("obj.regicide_cloth") + } + } + row("dbrow.crafting_weave_bolt_of_linen") { + production { + input("obj.linen_yarn", 2) + statReq("stat.crafting", 12) + xp(200) + output("obj.bolt_of_linen") + } + } + row("dbrow.crafting_weave_empty_sack") { + production { + input("obj.jute_fibre", 4) + statReq("stat.crafting", 21) + xp(380) + output("obj.sack_empty") + } + } + row("dbrow.crafting_weave_drift_net") { + production { + input("obj.jute_fibre", 2) + statReq("stat.crafting", 26) + xp(550) + output("obj.fossil_drift_net") + } + } + row("dbrow.crafting_weave_basket") { + production { + input("obj.willow_branch", 6) + statReq("stat.crafting", 36) + xp(560) + output("obj.basket_empty") + } + column(COL_TICKS, 4) + } + row("dbrow.crafting_weave_bolt_of_canvas") { + production { + input("obj.hemp_yarn", 2) + statReq("stat.crafting", 39) + xp(750) + output("obj.bolt_of_canvas") + } + } + row("dbrow.crafting_weave_bolt_of_cotton") { + production { + input("obj.cotton_yarn", 2) + statReq("stat.crafting", 73) + xp(1320) + output("obj.bolt_of_cotton") + } + } + } + section("PotteryShaping", category = "Shape") { + row("dbrow.crafting_shape_pot") { + production { + input("obj.softclay") + statReq("stat.crafting", 1) + xp(63) + output("obj.pot_unfired") + } + } + row("dbrow.crafting_shape_cup") { + production { + input("obj.softclay") + statReq("stat.crafting", 3) + xp(85) + output("obj.cup_unfired", 4) + } + } + row("dbrow.crafting_shape_pie_dish") { + production { + input("obj.softclay") + statReq("stat.crafting", 7) + xp(150) + output("obj.piedish_unfired") + } + } + row("dbrow.crafting_shape_bowl") { + production { + input("obj.softclay") + statReq("stat.crafting", 8) + xp(180) + output("obj.bowl_unfired") + } + } + row("dbrow.crafting_shape_plant_pot") { + production { + input("obj.softclay") + statReq("stat.crafting", 19) + xp(200) + output("obj.plantpot_unfired") + } + } + + row("dbrow.crafting_shape_pot_lid") { + production { + input("obj.softclay") + statReq("stat.crafting", 25) + xp(200) + output("obj.potlid_unfired") + } + } + } + section("PotteryFiring", category = "Fire") { + row("dbrow.crafting_fire_pot") { + production { + input("obj.pot_unfired") + statReq("stat.crafting", 1) + xp(63) + output("obj.pot_empty") + } + column(COL_SUCCESS_LOW, 180) + column(COL_SUCCESS_HIGH, 789) + } + row("dbrow.crafting_fire_cup") { + production { + input("obj.cup_unfired") + statReq("stat.crafting", 3) + xp(85) + output("obj.cup_empty") + } + } + row("dbrow.crafting_fire_pie_dish") { + production { + input("obj.piedish_unfired") + statReq("stat.crafting", 7) + xp(100) + output("obj.piedish") + } + column(COL_SUCCESS_LOW, 180) + column(COL_SUCCESS_HIGH, 789) + } + row("dbrow.crafting_fire_bowl") { + production { + input("obj.bowl_unfired") + statReq("stat.crafting", 8) + xp(150) + output("obj.bowl_empty") + } + column(COL_SUCCESS_LOW, 180) + column(COL_SUCCESS_HIGH, 789) + } + row("dbrow.crafting_fire_plant_pot") { + production { + input("obj.plantpot_unfired") + statReq("stat.crafting", 19) + xp(175) + output("obj.plantpot_empty") + } + column(COL_SUCCESS_LOW, 180) + column(COL_SUCCESS_HIGH, 789) + } + row("dbrow.crafting_fire_pot_lid") { + production { + input("obj.potlid_unfired") + statReq("stat.crafting", 25) + xp(200) + output("obj.potlid") + } + column(COL_SUCCESS_LOW, 180) + column(COL_SUCCESS_HIGH, 789) + } + } + section("GlassSmelting", category = "Smelt") { + row("dbrow.crafting_molten_glass") { + production { + input("obj.bucket_sand") + input("obj.soda_ash") + statReq("stat.crafting", 1) + xp(200) + output("obj.molten_glass") + output("obj.bucket_empty") + } + } + } + + section("SandPit", category = "Fill") { + row("dbrow.crafting_fill_bucket_sand") { + production { + input("obj.bucket_empty") + statReq("stat.crafting", 1) + xp(0) + output("obj.bucket_sand") + } + } + } + } + + fun hand() = craftingTable("dbtable.crafting_hand") { + + section("Needlework") { + row("dbrow.crafting_leather_gloves") { + production { + input("obj.leather") + statReq("stat.crafting", 1) + xp(138) + output("obj.leather_gloves") + category("Leather") + } + } + row("dbrow.crafting_leather_boots") { + production { + input("obj.leather") + statReq("stat.crafting", 7) + xp(163) + output("obj.leather_boots") + category("Leather") + } + } + row("dbrow.crafting_leather_cowl") { + production { + input("obj.leather") + statReq("stat.crafting", 9) + xp(185) + output("obj.leather_cowl") + category("Leather") + } + } + row("dbrow.crafting_leather_vambraces") { + production { + input("obj.leather") + statReq("stat.crafting", 11) + xp(220) + output("obj.leather_vambraces") + category("Leather") + } + } + row("dbrow.crafting_leather_body") { + production { + input("obj.leather") + statReq("stat.crafting", 14) + xp(250) + output("obj.leather_armour") + category("Leather") + } + } + row("dbrow.crafting_leather_chaps") { + production { + input("obj.leather") + statReq("stat.crafting", 18) + xp(270) + output("obj.leather_chaps") + category("Leather") + } + } + row("dbrow.crafting_hardleather_body") { + production { + input("obj.hard_leather") + statReq("stat.crafting", 28) + xp(350) + output("obj.hardleather_body") + category("Leather") + } + } + row("dbrow.crafting_coif") { + production { + input("obj.leather") + statReq("stat.crafting", 38) + xp(370) + output("obj.coif") + category("Leather") + } + } + row("dbrow.crafting_studded_body") { + production { + input("obj.leather_armour") + input("obj.studs") + statReq("stat.crafting", 41) + xp(400) + output("obj.studded_body") + category("Studded") + } + } + row("dbrow.crafting_studded_chaps") { + production { + input("obj.leather_chaps") + input("obj.studs") + statReq("stat.crafting", 44) + xp(420) + output("obj.studded_chaps") + category("Studded") + } + } + + row("dbrow.crafting_spiky_vambraces") { + production { + input("obj.leather_vambraces") + input("obj.huntingbeast_claws") + statReq("stat.crafting", 32) + xp(55) + output("obj.spiked_vambraces") + category("Studded") + } + column(COL_ANIM, "seq.human_crafting_spikedvambraces") + } + + row("dbrow.crafting_green_dhide_vambraces") { + production { + input("obj.dragon_leather") + statReq("stat.crafting", 57) + xp(620) + output("obj.dragon_vambraces") + category("Dragonhide") + } + } + row("dbrow.crafting_green_dhide_chaps") { + production { + input("obj.dragon_leather", 2) + statReq("stat.crafting", 60) + xp(1240) + output("obj.dragonhide_chaps") + category("Dragonhide") + } + } + row("dbrow.crafting_green_dhide_body") { + production { + input("obj.dragon_leather", 3) + statReq("stat.crafting", 63) + xp(1860) + output("obj.dragonhide_body") + category("Dragonhide") + } + } + row("dbrow.crafting_blue_dhide_vambraces") { + production { + input("obj.dragon_leather_blue") + statReq("stat.crafting", 66) + xp(700) + output("obj.blue_dragon_vambraces") + category("Dragonhide") + } + } + row("dbrow.crafting_blue_dhide_chaps") { + production { + input("obj.dragon_leather_blue", 2) + statReq("stat.crafting", 68) + xp(1400) + output("obj.blue_dragonhide_chaps") + category("Dragonhide") + } + } + row("dbrow.crafting_blue_dhide_body") { + production { + input("obj.dragon_leather_blue", 3) + statReq("stat.crafting", 71) + xp(2100) + output("obj.blue_dragonhide_body") + category("Dragonhide") + } + } + row("dbrow.crafting_red_dhide_vambraces") { + production { + input("obj.dragon_leather_red") + statReq("stat.crafting", 73) + xp(780) + output("obj.red_dragon_vambraces") + category("Dragonhide") + } + } + row("dbrow.crafting_red_dhide_chaps") { + production { + input("obj.dragon_leather_red", 2) + statReq("stat.crafting", 75) + xp(1560) + output("obj.red_dragonhide_chaps") + category("Dragonhide") + } + } + row("dbrow.crafting_red_dhide_body") { + production { + input("obj.dragon_leather_red", 3) + statReq("stat.crafting", 77) + xp(2340) + output("obj.red_dragonhide_body") + category("Dragonhide") + } + } + row("dbrow.crafting_black_dhide_vambraces") { + production { + input("obj.dragon_leather_black") + statReq("stat.crafting", 79) + xp(860) + output("obj.black_dragon_vambraces") + category("Dragonhide") + } + } + row("dbrow.crafting_black_dhide_chaps") { + production { + input("obj.dragon_leather_black", 2) + statReq("stat.crafting", 82) + xp(1720) + output("obj.black_dragonhide_chaps") + category("Dragonhide") + } + } + row("dbrow.crafting_black_dhide_body") { + production { + input("obj.dragon_leather_black", 3) + statReq("stat.crafting", 84) + xp(2580) + output("obj.black_dragonhide_body") + category("Dragonhide") + } + } + + + row("dbrow.crafting_snakeskin_boots") { + production { + input("obj.village_snake_skin", 6) + statReq("stat.crafting", 45) + xp(300) + output("obj.snakeskin_boots") + category("Snakeskin") + } + } + row("dbrow.crafting_snakeskin_vambraces") { + production { + input("obj.village_snake_skin", 8) + statReq("stat.crafting", 47) + xp(350) + output("obj.snakeskin_vambraces") + category("Snakeskin") + } + } + row("dbrow.crafting_snakeskin_bandana") { + production { + input("obj.village_snake_skin", 5) + statReq("stat.crafting", 48) + xp(450) + output("obj.snakeskin_bandana") + category("Snakeskin") + } + } + row("dbrow.crafting_snakeskin_chaps") { + production { + input("obj.village_snake_skin", 12) + statReq("stat.crafting", 51) + xp(500) + output("obj.snakeskin_chaps") + category("Snakeskin") + } + } + row("dbrow.crafting_snakeskin_body") { + production { + input("obj.village_snake_skin", 15) + statReq("stat.crafting", 53) + xp(550) + output("obj.snakeskin_body") + category("Snakeskin") + } + } + + + row("dbrow.crafting_yak_legs") { + production { + input("obj.yak_hide_cured") + statReq("stat.crafting", 43) + xp(320) + output("obj.yak_hide_armour_greaves") + category("Yak") + } + } + row("dbrow.crafting_yak_top") { + production { + input("obj.yak_hide_cured", 2) + statReq("stat.crafting", 46) + xp(320) + output("obj.yak_hide_armour_body") + category("Yak") + } + } + + + row("dbrow.crafting_xerician_hat") { + production { + input("obj.xeric_fabric", 3) + statReq("stat.crafting", 14) + xp(660) + output("obj.xeric_hat") + category("Xerician") + } + } + row("dbrow.crafting_xerician_robe") { + production { + input("obj.xeric_fabric", 4) + statReq("stat.crafting", 17) + xp(880) + output("obj.xeric_robe") + category("Xerician") + } + } + row("dbrow.crafting_xerician_top") { + production { + input("obj.xeric_fabric", 5) + statReq("stat.crafting", 22) + xp(1100) + output("obj.xeric_top") + category("Xerician") + } + } + + + row("dbrow.crafting_splitbark_gauntlets") { + production { + input("obj.hollow_bark", 1) + input("obj.fine_cloth", 1) + statReq("stat.crafting", 60) + xp(620) + output("obj.splitbark_gauntlets") + category("Splitbark") + } + } + row("dbrow.crafting_splitbark_boots") { + production { + input("obj.hollow_bark", 1) + input("obj.fine_cloth", 1) + statReq("stat.crafting", 60) + xp(620) + output("obj.splitbark_greaves") + category("Splitbark") + } + } + row("dbrow.crafting_splitbark_helm") { + production { + input("obj.hollow_bark", 2) + input("obj.fine_cloth", 2) + statReq("stat.crafting", 61) + xp(1240) + output("obj.splitbark_helm") + category("Splitbark") + } + } + row("dbrow.crafting_splitbark_legs") { + production { + input("obj.hollow_bark", 3) + input("obj.fine_cloth", 3) + statReq("stat.crafting", 62) + xp(1860) + output("obj.splitbark_legs") + category("Splitbark") + } + } + row("dbrow.crafting_splitbark_body") { + production { + input("obj.hollow_bark", 4) + input("obj.fine_cloth", 4) + statReq("stat.crafting", 62) + xp(2480) + output("obj.splitbark_body") + category("Splitbark") + } + } + + + row("dbrow.crafting_hueycoatl_vambraces") { + production { + input("obj.huey_hide", 1) + statReq("stat.crafting", 76) + xp(950) + output("obj.huey_vambraces") + category("Hueycoatl") + } + } + row("dbrow.crafting_hueycoatl_coif") { + production { + input("obj.huey_hide", 2) + statReq("stat.crafting", 76) + xp(1900) + output("obj.huey_coif") + category("Hueycoatl") + } + } + row("dbrow.crafting_hueycoatl_chaps") { + production { + input("obj.huey_hide", 2) + statReq("stat.crafting", 77) + xp(1900) + output("obj.huey_chaps") + category("Hueycoatl") + } + } + row("dbrow.crafting_hueycoatl_body") { + production { + input("obj.huey_hide", 3) + statReq("stat.crafting", 78) + xp(2850) + output("obj.huey_body") + category("Hueycoatl") + } + } + + + row("dbrow.crafting_mixed_hide_cape") { + production { + input("obj.hg_mixedhide_base") + input("obj.varlamore_jaguar_fur") + statReq("stat.crafting", 68) + xp(620) + output("obj.hide_cape") + category("MixedHide") + } + } + row("dbrow.crafting_mixed_hide_boots") { + production { + input("obj.hg_mixedhide_base") + input("obj.hunting_antelopesun_fur") + statReq("stat.crafting", 69) + xp(750) + output("obj.hide_boots") + category("MixedHide") + } + } + row("dbrow.crafting_mixed_hide_legs") { + production { + input("obj.hg_mixedhide_base") + input("obj.hunting_fennecfox_fur", 3) + statReq("stat.crafting", 71) + xp(2100) + output("obj.hide_legs") + category("MixedHide") + } + } + row("dbrow.crafting_mixed_hide_top") { + production { + input("obj.hg_mixedhide_base") + input("obj.hunting_antelopesun_fur", 2) + statReq("stat.crafting", 72) + xp(1500) + output("obj.hide_top") + category("MixedHide") + } + } + } + + section("Shields", category = "Shield") { + row("dbrow.crafting_hard_leather_shield") { + production { + input("obj.hard_leather", 2) + input("obj.oak_shield", 1) + input("obj.nails_bronze", 15) + statReq("stat.crafting", 41) + xp(700) + output("obj.leather_shield") + } + column(COL_ANIM, "seq.human_shield_crafting_leather") + } + row("dbrow.crafting_snakeskin_shield") { + production { + input("obj.village_snake_skin", 2) + input("obj.willow_shield", 1) + input("obj.nails_iron", 15) + statReq("stat.crafting", 56) + xp(1000) + output("obj.snakeskin_shield") + } + column(COL_ANIM, "seq.human_shield_crafting_snakeskin") + } + row("dbrow.crafting_green_dhide_shield") { + production { + input("obj.dragon_leather", 2) + input("obj.maple_shield", 1) + input("obj.nails", 15) + statReq("stat.crafting", 62) + xp(1240) + output("obj.green_dhide_shield") + } + column(COL_ANIM, "seq.human_shield_crafting_green_dhide") + } + row("dbrow.crafting_blue_dhide_shield") { + production { + input("obj.dragon_leather_blue", 2) + input("obj.yew_shield", 1) + input("obj.nails_mithril", 15) + statReq("stat.crafting", 69) + xp(1400) + output("obj.blue_dhide_shield") + } + column(COL_ANIM, "seq.human_shield_crafting_blue_dhide") + } + row("dbrow.crafting_red_dhide_shield") { + production { + input("obj.dragon_leather_red", 2) + input("obj.magic_shield", 1) + input("obj.nails_adamant", 15) + statReq("stat.crafting", 76) + xp(1560) + output("obj.red_dhide_shield") + } + column(COL_ANIM, "seq.human_shield_crafting_red_dhide") + } + row("dbrow.crafting_black_dhide_shield") { + production { + input("obj.dragon_leather_black", 2) + input("obj.redwood_shield", 1) + input("obj.nails_rune", 15) + statReq("stat.crafting", 83) + xp(1720) + output("obj.black_dhide_shield") + } + column(COL_ANIM, "seq.human_shield_crafting_black_dhide") + } + row("dbrow.crafting_broodoo_shield_blue") { + production { + input("obj.village_snake_skin", 2) + input("obj.broodoo_combatshield", 1) + input("obj.nails", 8) + statReq("stat.crafting", 35) + xp(1000) + output("obj.broodoo_combatshield") + } + column(COL_ANIM, "seq.human_shield_crafting_disease") + } + row("dbrow.crafting_broodoo_shield_green") { + production { + input("obj.village_snake_skin", 2) + input("obj.broodoo_poisonshield", 1) + input("obj.nails", 8) + statReq("stat.crafting", 35) + xp(1000) + output("obj.broodoo_poisonshield") + } + column(COL_ANIM, "seq.human_shield_crafting_poison") + } + row("dbrow.crafting_broodoo_shield_orange") { + production { + input("obj.village_snake_skin", 2) + input("obj.broodoo_diseaseshield", 1) + input("obj.nails", 8) + statReq("stat.crafting", 35) + xp(1000) + output("obj.broodoo_diseaseshield") + } + column(COL_ANIM, "seq.human_shield_crafting_combat") + } + } + + section("Carving", category = "Carve") { + row("dbrow.crafting_snelm_red_pointed") { + production { + input("obj.shellpoint_red+black") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_point_red+black") + } + } + row("dbrow.crafting_snelm_red_round") { + production { + input("obj.shellround_red+black") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_round_red+black") + } + } + row("dbrow.crafting_snelm_bark") { + production { + input("obj.shellround_orange") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_round_orange") + } + } + row("dbrow.crafting_snelm_blue_pointed") { + production { + input("obj.shellpoint_blue") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_point_blue") + } + } + row("dbrow.crafting_snelm_blue_round") { + production { + input("obj.shellround_blue") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_round_blue") + } + } + row("dbrow.crafting_snelm_myre_pointed") { + production { + input("obj.shellpoint_swamp") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_point_swamp") + } + } + row("dbrow.crafting_snelm_myre_round") { + production { + input("obj.shellround_swamp") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_round_swamp") + } + } + row("dbrow.crafting_snelm_ochre_pointed") { + production { + input("obj.shellpoint_yellow") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_point_yellow") + } + } + row("dbrow.crafting_snelm_ochre_round") { + production { + input("obj.shellround_yellow") + statReq("stat.crafting", 15) + xp(325) + output("obj.snelm_round_yellow") + } + } + row("dbrow.crafting_crab_helmet") { + production { + input("obj.hundred_pirate_crab_shell_head") + statReq("stat.crafting", 15) + xp(325) + output("obj.hundred_pirate_crab_shell_helm") + } + } + row("dbrow.crafting_crab_claw") { + production { + input("obj.hundred_pirate_crab_shell_claw") + statReq("stat.crafting", 15) + xp(325) + output("obj.hundred_pirate_crab_shell_gauntlet") + } + } + } + section("Knife", category = "Cut") { + row("dbrow.crafting_dramen_staff") { + production { + input("obj.dramen_branch") + statReq("stat.crafting", 31) + xp(0) + output("obj.dramen_staff") + } + column(COL_TICKS, 0) + column(COL_SPAM_MESSAGE, "You carve the branch into a staff.") + } + + row("dbrow.crafting_sinew") { + production { + input("obj.damaged_ballista_rope") + statReq("stat.crafting", 10) + xp(150) + output("obj.xbows_sinew") + } + } + } + section("Gems", category = "Cut") { + row("dbrow.crafting_cut_opal") { + production { + input("obj.uncut_opal") + statReq("stat.crafting", 1) + xp(150) + output("obj.opal") + } + column(COL_ANIM, "seq.human_opalcutting") + column(COL_SUCCESS_LOW, 100) + column(COL_SUCCESS_HIGH, 252) + column(COL_FAIL_XP, 38) + columnRSCM(COL_FAIL_ITEM, "obj.crushed_gemstone") + } + row("dbrow.crafting_cut_jade") { + production { + input("obj.uncut_jade") + statReq("stat.crafting", 13) + xp(200) + output("obj.jade") + } + column(COL_ANIM, "seq.human_jadecutting") + column(COL_SUCCESS_LOW, 120) + column(COL_SUCCESS_HIGH, 252) + column(COL_FAIL_XP, 50) + columnRSCM(COL_FAIL_ITEM, "obj.crushed_gemstone") + } + row("dbrow.crafting_cut_red_topaz") { + production { + input("obj.uncut_red_topaz") + statReq("stat.crafting", 16) + xp(250) + output("obj.red_topaz") + } + column(COL_ANIM, "seq.human_redtopazcutting") + column(COL_SUCCESS_LOW, 140) + column(COL_SUCCESS_HIGH, 252) + column(COL_FAIL_XP, 63) + columnRSCM(COL_FAIL_ITEM, "obj.crushed_gemstone") + } + row("dbrow.crafting_cut_sapphire") { + production { + input("obj.uncut_sapphire") + statReq("stat.crafting", 20) + xp(500) + output("obj.sapphire") + } + column(COL_ANIM, "seq.human_sapphirecutting") + } + row("dbrow.crafting_cut_emerald") { + production { + input("obj.uncut_emerald") + statReq("stat.crafting", 27) + xp(675) + output("obj.emerald") + } + column(COL_ANIM, "seq.human_emeraldcutting") + } + row("dbrow.crafting_cut_ruby") { + production { + input("obj.uncut_ruby") + statReq("stat.crafting", 34) + xp(850) + output("obj.ruby") + } + column(COL_ANIM, "seq.human_rubycutting") + } + row("dbrow.crafting_cut_diamond") { + production { + input("obj.uncut_diamond") + statReq("stat.crafting", 43) + xp(1075) + output("obj.diamond") + } + column(COL_ANIM, "seq.human_diamondcutting") + } + row("dbrow.crafting_cut_dragonstone") { + production { + input("obj.uncut_dragonstone") + statReq("stat.crafting", 55) + xp(1375) + output("obj.dragonstone") + } + column(COL_ANIM, "seq.human_dragonstonecutting") + } + row("dbrow.crafting_cut_onyx") { + production { + input("obj.uncut_onyx") + statReq("stat.crafting", 67) + xp(1675) + output("obj.onyx") + } + column(COL_ANIM, "seq.human_onyxcutting") + } + row("dbrow.crafting_cut_zenyte") { + production { + input("obj.uncut_zenyte") + statReq("stat.crafting", 89) + xp(2000) + output("obj.zenyte") + } + column(COL_ANIM, "seq.human_zenytecutting") + } + } + section("Amethyst", category = "Cut") { + row("dbrow.crafting_amethyst_bolt_tips") { + production { + input("obj.amethyst") + statReq("stat.crafting", 83) + xp(600) + output("obj.xbows_bolt_tips_amethyst", 15) + } + } + row("dbrow.crafting_amethyst_arrowtips") { + production { + input("obj.amethyst") + statReq("stat.crafting", 85) + xp(600) + output("obj.amethyst_arrowheads", 15) + } + } + row("dbrow.crafting_amethyst_javelin_heads") { + production { + input("obj.amethyst") + statReq("stat.crafting", 87) + xp(600) + output("obj.amethyst_javelin_head", 5) + } + } + row("dbrow.crafting_amethyst_dart_tips") { + production { + input("obj.amethyst") + statReq("stat.crafting", 89) + xp(600) + output("obj.amethyst_dart_tip", 8) + } + } + } + section("Limestone", category = "Cut") { + // 137/434 out of 256 reproduces the wiki curve. ~33% failure at level 12 and 100% to success at level 40. + row("dbrow.crafting_limestone_brick") { + production { + input("obj.limestone") + statReq("stat.crafting", 12) + xp(60) + output("obj.limestonebrick") + } + column(COL_SUCCESS_LOW, 137) + column(COL_SUCCESS_HIGH, 434) + columnRSCM(COL_FAIL_ITEM, "obj.rock") + } + } + section("Glassblowing", category = "Blow") { + row("dbrow.crafting_glass_beer_glass") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 1) + xp(175) + output("obj.beer_glass") + } + } + row("dbrow.crafting_glass_candle_lantern") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 4) + xp(190) + output("obj.candle_lantern_empty") + } + } + row("dbrow.crafting_glass_oil_lamp") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 12) + xp(250) + output("obj.oil_lamp_empty") + } + } + row("dbrow.crafting_glass_vial") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 33) + xp(350) + output("obj.vial_empty") + } + } + row("dbrow.crafting_glass_fishbowl") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 42) + xp(425) + output("obj.fishbowl_empty") + } + } + row("dbrow.crafting_glass_unpowered_orb") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 46) + xp(525) + output("obj.stafforb") + } + } + row("dbrow.crafting_glass_lantern_lens") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 49) + xp(550) + output("obj.bullseye_lantern_lens") + } + } + row("dbrow.crafting_glass_light_orb") { + production { + input("obj.molten_glass") + statReq("stat.crafting", 87) + xp(700) + output("obj.dorgesh_lightbulb_nofilament") + } + } + } + + section("Battlestaves", category = "Attach") { + row("dbrow.crafting_water_battlestaff") { + production { + input("obj.battlestaff") + input("obj.water_orb") + statReq("stat.crafting", 54) + xp(1000) + output("obj.water_battlestaff") + } + column(COL_SPOTANIM, "spotanim.battlestaff_water_crafting_spotanim") + } + row("dbrow.crafting_earth_battlestaff") { + production { + input("obj.battlestaff") + input("obj.earth_orb") + statReq("stat.crafting", 58) + xp(1125) + output("obj.earth_battlestaff") + } + column(COL_SPOTANIM, "spotanim.battlestaff_earth_crafting_spotanim") + } + row("dbrow.crafting_fire_battlestaff") { + production { + input("obj.battlestaff") + input("obj.fire_orb") + statReq("stat.crafting", 62) + xp(1250) + output("obj.fire_battlestaff") + } + column(COL_SPOTANIM, "spotanim.battlestaff_fire_crafting_spotanim") + } + row("dbrow.crafting_air_battlestaff") { + production { + input("obj.battlestaff") + input("obj.air_orb") + statReq("stat.crafting", 66) + xp(1375) + output("obj.air_battlestaff") + } + column(COL_SPOTANIM, "spotanim.battlestaff_air_crafting_spotanim") + } + } + + section("AmuletStringing", category = "String") { + row("dbrow.crafting_string_gold_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_gold_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_gold_amulet") + } + } + row("dbrow.crafting_string_sapphire_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_sapphire_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_sapphire_amulet") + } + } + row("dbrow.crafting_string_emerald_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_emerald_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_emerald_amulet") + } + } + row("dbrow.crafting_string_ruby_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_ruby_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_ruby_amulet") + } + } + row("dbrow.crafting_string_diamond_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_diamond_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_diamond_amulet") + } + } + row("dbrow.crafting_string_dragonstone_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_dragonstone_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_dragonstone_amulet") + } + } + row("dbrow.crafting_string_onyx_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_onyx_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_onyx_amulet") + } + } + row("dbrow.crafting_string_zenyte_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_zenyte_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.zenyte_amulet") + } + } + row("dbrow.crafting_string_opal_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_opal_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_opal_amulet") + } + } + row("dbrow.crafting_string_jade_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_jade_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_jade_amulet") + } + } + row("dbrow.crafting_string_topaz_amulet") { + production { + input("obj.ball_of_wool") + input("obj.unstrung_topaz_amulet") + statReq("stat.crafting", 1) + xp(40) + output("obj.strung_topaz_amulet") + } + } + + row("dbrow.crafting_string_emblem") { + production { + input("obj.ball_of_wool") + input("obj.nostringsnake") + statReq("stat.crafting", 1) + xp(40) + output("obj.stringsnake") + } + } + + row("dbrow.crafting_string_symbol") { + production { + input("obj.ball_of_wool") + input("obj.nostringstar") + statReq("stat.crafting", 1) + xp(40) + output("obj.stringstar") + } + } + } + section("Birdhouses", category = "Birdhouse") { + row("dbrow.crafting_birdhouse_normal") { + production { + input("obj.logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 5) + xp(150) + output("obj.birdhouse_normal") + } + } + row("dbrow.crafting_birdhouse_oak") { + production { + input("obj.oak_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 15) + xp(200) + output("obj.birdhouse_oak") + } + } + row("dbrow.crafting_birdhouse_willow") { + production { + input("obj.willow_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 25) + xp(250) + output("obj.birdhouse_willow") + } + } + row("dbrow.crafting_birdhouse_teak") { + production { + input("obj.teak_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 35) + xp(300) + output("obj.birdhouse_teak") + } + } + row("dbrow.crafting_birdhouse_maple") { + production { + input("obj.maple_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 45) + xp(350) + output("obj.birdhouse_maple") + } + } + row("dbrow.crafting_birdhouse_mahogany") { + production { + input("obj.mahogany_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 50) + xp(400) + output("obj.birdhouse_mahogany") + } + } + row("dbrow.crafting_birdhouse_yew") { + production { + input("obj.yew_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 60) + xp(450) + output("obj.birdhouse_yew") + } + } + row("dbrow.crafting_birdhouse_magic") { + production { + input("obj.magic_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 75) + xp(500) + output("obj.birdhouse_magic") + } + } + row("dbrow.crafting_birdhouse_redwood") { + production { + input("obj.redwood_logs") + input("obj.poh_clockwork_mechanism") + statReq("stat.crafting", 90) + xp(550) + output("obj.birdhouse_redwood") + } + } + } + + section("Combining") { + row("dbrow.crafting_slayer_helm") { + production { + category("Assembly") + input("obj.harmless_black_mask") + input("obj.slayer_earmuffs") + input("obj.slayer_facemask") + input("obj.slayer_nosepeg") + input("obj.wallbeast_spike_helmet") + input("obj.slayer_gem") + statReq("stat.crafting", 55) + xp(0) + output("obj.slayer_helm") + } + column(COL_SPAM_MESSAGE, "You combine the pieces to make a {output}.") + column(COL_QUEST_REQ, "quest_porcineofinterest", QuestReq.NotCompleted.id) + column(COL_UNLOCK_VARBIT, "varbit.slayer_helm_unlocked", VarbitCompare.GTE.id, 1) + column(COL_LOCKED_MESSAGE, "You need to learn how to combine these items first. Speak to a Slayer master about the 'Malevolent masquerade' ability.") + } + + row("dbrow.crafting_slayer_helm_goggles") { + production { + category("Assembly") + input("obj.harmless_black_mask") + input("obj.slayer_earmuffs") + input("obj.slayer_facemask") + input("obj.slayer_nosepeg") + input("obj.wallbeast_spike_helmet") + input("obj.slayer_gem") + input("obj.slayer_reinforced_goggles") + statReq("stat.crafting", 55) + xp(0) + output("obj.slayer_helm") + } + column(COL_SPAM_MESSAGE, "You combine the pieces to make a {output}.") + column(COL_QUEST_REQ, "quest_porcineofinterest", QuestReq.Completed.id) + column(COL_UNLOCK_VARBIT, "varbit.slayer_helm_unlocked", VarbitCompare.GTE.id, 1) + column(COL_LOCKED_MESSAGE, "You need to learn how to combine these items first. Speak to a Slayer master about the 'Malevolent masquerade' ability.") + } + + row("dbrow.crafting_noxious_halberd") { + production { + category("Assembly") + input("obj.noxious_halberd_part_1") + input("obj.noxious_halberd_part_2") + input("obj.noxious_halberd_part_3") + statReq("stat.crafting", 72) + statReq("stat.smithing", 72) + xp(1000) + output("obj.noxious_halberd") + } + column(COL_ANIM, "seq.human_fletching_noxious_halberd") + column(COL_XP_EXTRA, ConstantProvider.getMapping("stat.smithing"), 1000) + column(COL_CONFIRM_TITLE, "Do you wish to create a noxious halberd?") + column(COL_CONFIRM_WARNING, "Do you wish to combine all three pieces to create a noxious halberd?
This process is non-reversible") + column(COL_RESULT_DIALOGUE, "You successfully create a noxious halberd.") + } + + row("dbrow.crafting_amulet_of_rancour") { + production { + category("Assembly") + input("obj.zenyte_amulet_enchanted") + input("obj.araxyte_fang") + statReq("stat.crafting", 86) + xp(5000) + output("obj.amulet_of_rancour") + } + column(COL_TICKS, 36) + column(COL_ANIM, "seq.human_craft_rancor_start", "seq.human_craft_rancor_end") + column(COL_SPOTANIM, "spotanim.vfx_human_craft_rancor_start", "spotanim.vfx_human_craft_rancor_end") + column(COL_CONFIRM_WARNING, "Do you wish to use the araxyte fang on your amulet of torture?
This process is non-reversible and will consume both items.") + column(COL_CONFIRM_TITLE, "Do you wish to create a amulet of rancour?") //misspelling on purpose since it's accurate + column(COL_RESULT_DIALOGUE, "You successfully create an amulet of rancour.") + } + + row("dbrow.crafting_necklace_of_rupture") { + production { + category("Assembly") + input("obj.zenyte_necklace_enchanted") + input("obj.etched_elder_venator_fang") + statReq("stat.crafting", 84) + xp(5000) + output("obj.necklace_of_rupture") + } + column(COL_TICKS, 36) + column(COL_ANIM, "seq.human_craft_rupture_start", "seq.human_craft_rupture_end") + column(COL_SPOTANIM, "spotanim.vfx_human_craft_rupture_start", "spotanim.vfx_human_craft_rupture_end") + column(COL_CONFIRM_WARNING, "Do you wish to use the etched elder venator fang on your
necklace of anguish?
This process is non-reversible and will consume both items.") + column(COL_CONFIRM_TITLE, "Do you wish to create a necklace of rupture?") + column(COL_RESULT_DIALOGUE, "You successfully create a necklace of rupture.") + } + + row("dbrow.crafting_confliction_gauntlets") { + production { + category("Assembly") + input("obj.zenyte_bracelet_enchanted") + input("obj.mokhaiotl_cloth") + input("obj.demon_tear", 10000) + statReq("stat.crafting", 83) + statReq("stat.smithing", 70) + xp(5000) + output("obj.confliction_gauntlets") + } + column(COL_TICKS, 36) + column(COL_XP_EXTRA, ConstantProvider.getMapping("stat.smithing"), 1000) + column(COL_ANIM, "seq.human_craft_confliction") + column(COL_SPOTANIM, "spotanim.spotanim_confliction_craft") + column(COL_CONFIRM_WARNING, "Do you wish to make a pair of confliction gauntlets
" + + "from the mokhaiotl cloth, the tormented bracelets and
" + + "10,000 demon tears? This process is non-reversible and
" + + "will consume the items used.") + column(COL_CONFIRM_TITLE, "Do you wish to make a pair of confliction gauntlets?") + column(COL_RESULT_DIALOGUE, "You carefully craft a pair of confliction gauntlets.") + } + + row("dbrow.crafting_toxic_staff_of_the_dead") { + production { + category("Chisel") + input("obj.magic_fang") + input("obj.sotd") + statReq("stat.crafting", 59) + xp(0) + output("obj.toxic_sotd") + } + columnRSCM(COL_TOOL, "obj.chisel") + column(COL_SOUND, "synth.chisel") + } + row("dbrow.crafting_trident_of_the_swamp") { + production { + category("Chisel") + input("obj.magic_fang") + input("obj.tots_uncharged") + statReq("stat.crafting", 59) + xp(0) + output("obj.toxic_tots_uncharged") + } + columnRSCM(COL_TOOL, "obj.chisel") + column(COL_SOUND, "synth.chisel") + } + + row("dbrow.crafting_bone_staff") { + production { + category("Chisel") + input("obj.rat_boss_spine") + input("obj.battlestaff") + input("obj.chaosrune", 1000) + statReq("stat.crafting", 35) + xp(0) + output("obj.rat_bone_staff") + } + columnRSCM(COL_TRIGGERS, "obj.rat_boss_spine", "obj.battlestaff") + columnRSCM(COL_TOOL, "obj.chisel") + column(COL_SOUND, "synth.chisel") + } + + row("dbrow.crafting_accursed_sceptre") { + production { + category("Attach") + input("obj.wbr_vetion_skull") + input("obj.wild_cave_sceptre_uncharged") + statReq("stat.crafting", 85) + xp(0) + output("obj.wild_cave_accursed_uncharged") + } + } + + row("dbrow.crafting_strung_rabbit_foot") { + production { + category("String") + input("obj.hunting_rabbit_foot") + input("obj.ball_of_wool") + statReq("stat.crafting", 37) + xp(40) + output("obj.hunting_strung_rabbit_foot") + } + column(COL_SOUND, "synth.stringing") + column(COL_SPAM_MESSAGE, "You string the {input}.") + column(COL_ACTION_NAME, "string a {input}") + } + + row("dbrow.crafting_serpentine_helm") { + production { + category("Chisel") + input("obj.serpentine_visage") + statReq("stat.crafting", 52) + xp(1200) + output("obj.serpentine_helm") + } + columnRSCM(COL_TOOL, "obj.chisel") + column(COL_SOUND, "synth.chisel") + column(COL_RESULT_DIALOGUE, "You adapt the visage to fit on a human head.") + } + + row("dbrow.crafting_break_armadyl_chestplate") { + production { + category("Chisel") + input("obj.armadyl_chestplate") + statReq("stat.crafting", 90) + xp(8400) + output("obj.armadylean_component", 4) + } + columnRSCM(COL_TOOL, "obj.chisel") + column(COL_SPAM_MESSAGE, "You use your chisel to break apart the armour down into its base components.") + column( + COL_CONFIRM_TITLE, + "Break apart your Armadyl chestplate into 4 Armadylean plates?", + "Really break apart your Armadyl chestplate into 4 Armadylean plates?", + ) + } + row("dbrow.crafting_break_armadyl_skirt") { + production { + category("Chisel") + input("obj.armadyl_skirt") + statReq("stat.crafting", 90) + xp(6300) + output("obj.armadylean_component", 3) + } + columnRSCM(COL_TOOL, "obj.chisel") + column(COL_SPAM_MESSAGE, "You use your chisel to break apart the armour down into its base components.") + column( + COL_CONFIRM_TITLE, + "Break apart your Armadyl chainskirt into 3 Armadylean plates?", + "Really break apart your Armadyl chainskirt into 3 Armadylean plates?", + ) + } + row("dbrow.crafting_break_armadyl_helmet") { + production { + category("Chisel") + input("obj.armadyl_helmet") + statReq("stat.crafting", 90) + xp(2100) + output("obj.armadylean_component", 1) + } + columnRSCM(COL_TOOL, "obj.chisel") + column(COL_SPAM_MESSAGE, "You use your chisel to break apart the armour down into its base components.") + column( + COL_CONFIRM_TITLE, + "Break apart your Armadyl helmet into 1 Armadylean plate?", + "Really break apart your Armadyl helmet into 1 Armadylean plate?", + ) + } + row("dbrow.crafting_fortify_masori_body") { + production { + category("Hammer") + input("obj.masori_body") + input("obj.armadylean_component", 4) + statReq("stat.crafting", 90) + xp(33200) + output("obj.masori_body_fortified") + } + columnRSCM(COL_TOOL, "obj.hammer") + column(COL_CONFIRM_TITLE, "Fortify your Masori body with 4 Armadylean plates?") + column(COL_RESULT_DIALOGUE, "You use 4 Armadylean plates to fortify the Masori body.") + } + row("dbrow.crafting_fortify_masori_chaps") { + production { + category("Hammer") + input("obj.masori_chaps") + input("obj.armadylean_component", 3) + statReq("stat.crafting", 90) + xp(24900) + output("obj.masori_chaps_fortified") + } + columnRSCM(COL_TOOL, "obj.hammer") + column(COL_CONFIRM_TITLE, "Fortify your Masori chaps with 3 Armadylean plates?") + column(COL_RESULT_DIALOGUE, "You use 3 Armadylean plates to fortify the Masori chaps.") + } + row("dbrow.crafting_fortify_masori_mask") { + production { + category("Hammer") + input("obj.masori_mask") + input("obj.armadylean_component", 1) + statReq("stat.crafting", 90) + xp(8300) + output("obj.masori_mask_fortified") + } + columnRSCM(COL_TOOL, "obj.hammer") + column(COL_CONFIRM_TITLE, "Fortify your Masori mask with 1 Armadylean plate?") + column(COL_RESULT_DIALOGUE, "You use 1 Armadylean plate to fortify the Masori mask.") + } + row("dbrow.crafting_light_orb") { + production { + input("obj.dorgesh_lightbulb_nofilament") + input("obj.dorgesh_wire") + statReq("stat.crafting", 87) + xp(1040) + output("obj.dorgesh_light_bulb") + } + column(COL_SPAM_MESSAGE, "") + } + + } + + section("SoftClayMixing") { + row("dbrow.crafting_soft_clay_bucket_water") { + production { + input("obj.clay") + input("obj.bucket_water") + statReq("stat.crafting", 1) + xp(10) + output("obj.softclay") + output("obj.bucket_empty") + } + } + row("dbrow.crafting_soft_clay_jug_water") { + production { + input("obj.clay") + input("obj.jug_water") + statReq("stat.crafting", 1) + xp(10) + output("obj.softclay") + output("obj.jug_empty") + } + } + row("dbrow.crafting_soft_clay_bowl_water") { + production { + input("obj.clay") + input("obj.bowl_water") + statReq("stat.crafting", 1) + xp(10) + output("obj.softclay") + output("obj.bowl_empty") + } + } + row("dbrow.crafting_soft_clay_cup_water") { + production { + input("obj.clay") + input("obj.cup_water") + statReq("stat.crafting", 1) + xp(10) + output("obj.softclay") + output("obj.cup_empty") + } + } + } + + section("PheasantCostume") { + row("dbrow.crafting_pheasant_boots") { + production { + input("obj.forestry_pheasant_feathers", 15) + statReq("stat.crafting", 2) + xp(150) + output("obj.forestry_pheasant_boots") + } + } + row("dbrow.crafting_pheasant_hat") { + production { + input("obj.forestry_pheasant_feathers", 15) + statReq("stat.crafting", 2) + xp(150) + output("obj.forestry_pheasant_hat") + } + } + row("dbrow.crafting_pheasant_legs") { + production { + input("obj.forestry_pheasant_feathers", 15) + statReq("stat.crafting", 2) + xp(150) + output("obj.forestry_pheasant_legs") + } + } + row("dbrow.crafting_pheasant_cape") { + production { + input("obj.forestry_pheasant_feathers", 15) + statReq("stat.crafting", 2) + xp(150) + output("obj.forestry_pheasant_cape") + } + } + } + + section("Tanning", category = "Tan") { + row("dbrow.crafting_tan_soft_leather") { + production { + input("obj.cow_hide") + statReq("stat.crafting", 1) + xp(0) + output("obj.leather") + } + column(COL_COST, 1) + } + row("dbrow.crafting_tan_hard_leather") { + production { + input("obj.cow_hide") + statReq("stat.crafting", 1) + xp(0) + output("obj.hard_leather") + } + column(COL_COST, 3) + } + row("dbrow.crafting_tan_snakeskin") { + production { + input("obj.village_snake_hide") + statReq("stat.crafting", 1) + xp(0) + output("obj.village_snake_skin") + } + column(COL_COST, 15) + } + row("dbrow.crafting_tan_snakeskin_swamp") { + production { + input("obj.templetrek_swamp_snake_hide") + statReq("stat.crafting", 1) + xp(0) + output("obj.village_snake_skin") + } + column(COL_COST, 20) + } + row("dbrow.crafting_tan_green_dhide") { + production { + input("obj.dragonhide_green") + statReq("stat.crafting", 1) + xp(0) + output("obj.dragon_leather") + } + column(COL_COST, 20) + } + row("dbrow.crafting_tan_blue_dhide") { + production { + input("obj.dragonhide_blue") + statReq("stat.crafting", 1) + xp(0) + output("obj.dragon_leather_blue") + } + column(COL_COST, 20) + } + row("dbrow.crafting_tan_red_dhide") { + production { + input("obj.dragonhide_red") + statReq("stat.crafting", 1) + xp(0) + output("obj.dragon_leather_red") + } + column(COL_COST, 20) + } + row("dbrow.crafting_tan_black_dhide") { + production { + input("obj.dragonhide_black") + statReq("stat.crafting", 1) + xp(0) + output("obj.dragon_leather_black") + } + column(COL_COST, 20) + } + row("dbrow.crafting_cure_yak_hide") { + production { + input("obj.yak_hide") + statReq("stat.crafting", 1) + xp(0) + output("obj.yak_hide_cured") + } + column(COL_COST, 5) + } + } + } + + fun silver() = craftingTable("dbtable.crafting_silver") { + section("Jewellery", category = "Silver") { + row("dbrow.crafting_unstrung_symbol") { + production { + input("obj.silver_bar") + statReq("stat.crafting", 16) + xp(500) + output("obj.nostringstar") + } + columnRSCM(COL_TOOL, "obj.holy_symbol_mould") + } + row("dbrow.crafting_unstrung_emblem") { + production { + input("obj.silver_bar") + statReq("stat.crafting", 17) + xp(500) + output("obj.nostringsnake") + } + columnRSCM(COL_TOOL, "obj.unholy_symbol_mould") + } + row("dbrow.crafting_silver_sickle") { + production { + input("obj.silver_bar") + statReq("stat.crafting", 18) + xp(500) + output("obj.silver_sickle") + } + columnRSCM(COL_TOOL, "obj.sickle_mould") + } + row("dbrow.crafting_silver_bolts") { + production { + input("obj.silver_bar") + statReq("stat.crafting", 21) + xp(500) + output("obj.xbows_crossbow_bolts_silver_unfeathered", 10) + } + columnRSCM(COL_TOOL, "obj.xbows_silver_bolt_mould") + } + row("dbrow.crafting_conductor") { + production { + input("obj.silver_bar") + statReq("stat.crafting", 20) + xp(500) + output("obj.fenk_conductor") + } + columnRSCM(COL_TOOL, "obj.fenk_lightning_mould") + } + row("dbrow.crafting_silvthrill_rod") { + production { + input("obj.silver_bar") + input("obj.mithril_bar") + input("obj.sapphire") + statReq("stat.crafting", 25) + xp(550) + output("obj.burgh_rod_command1") + } + columnRSCM(COL_TOOL, "obj.burgh_rod_clay") + } + row("dbrow.crafting_demonic_sigil") { + production { + input("obj.silver_bar") + statReq("stat.crafting", 30) + xp(500) + output("obj.agrith_sigil") + } + columnRSCM(COL_TOOL, "obj.agrith_sigil_mould") + } + row("dbrow.crafting_tiara") { + production { + input("obj.silver_bar") + statReq("stat.crafting", 23) + xp(525) + output("obj.tiara") + } + columnRSCM(COL_TOOL, "obj.tiara_mould") + } + + + row("dbrow.crafting_opal_ring") { + production { + input("obj.silver_bar") + input("obj.opal") + statReq("stat.crafting", 1) + xp(100) + output("obj.opal_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_opal_necklace") { + production { + input("obj.silver_bar") + input("obj.opal") + statReq("stat.crafting", 16) + xp(350) + output("obj.opal_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_opal_bracelet") { + production { + input("obj.silver_bar") + input("obj.opal") + statReq("stat.crafting", 22) + xp(450) + output("obj.opal_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_opal_amulet") { + production { + input("obj.silver_bar") + input("obj.opal") + statReq("stat.crafting", 27) + xp(550) + output("obj.unstrung_opal_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_jade_ring") { + production { + input("obj.silver_bar") + input("obj.jade") + statReq("stat.crafting", 13) + xp(320) + output("obj.jade_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_jade_necklace") { + production { + input("obj.silver_bar") + input("obj.jade") + statReq("stat.crafting", 25) + xp(540) + output("obj.jade_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_jade_bracelet") { + production { + input("obj.silver_bar") + input("obj.jade") + statReq("stat.crafting", 29) + xp(600) + output("obj.jade_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_jade_amulet") { + production { + input("obj.silver_bar") + input("obj.jade") + statReq("stat.crafting", 34) + xp(700) + output("obj.unstrung_jade_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_topaz_ring") { + production { + input("obj.silver_bar") + input("obj.red_topaz") + statReq("stat.crafting", 16) + xp(350) + output("obj.topaz_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_topaz_necklace") { + production { + input("obj.silver_bar") + input("obj.red_topaz") + statReq("stat.crafting", 32) + xp(700) + output("obj.topaz_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_topaz_bracelet") { + production { + input("obj.silver_bar") + input("obj.red_topaz") + statReq("stat.crafting", 38) + xp(750) + output("obj.topaz_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_topaz_amulet") { + production { + input("obj.silver_bar") + input("obj.red_topaz") + statReq("stat.crafting", 45) + xp(800) + output("obj.unstrung_topaz_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + } + } + + fun gold() = craftingTable("dbtable.crafting_gold") { + section("Jewellery", category = "Gold") { + row("dbrow.crafting_gold_ring") { + production { + input("obj.gold_bar") + statReq("stat.crafting", 5) + xp(150) + output("obj.gold_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_gold_necklace") { + production { + input("obj.gold_bar") + statReq("stat.crafting", 6) + xp(200) + output("obj.gold_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_gold_bracelet") { + production { + input("obj.gold_bar") + statReq("stat.crafting", 7) + xp(250) + output("obj.jewl_gold_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_gold_amulet") { + production { + input("obj.gold_bar") + statReq("stat.crafting", 8) + xp(300) + output("obj.unstrung_gold_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_sapphire_ring") { + production { + input("obj.gold_bar") + input("obj.sapphire") + statReq("stat.crafting", 20) + xp(400) + output("obj.sapphire_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_sapphire_necklace") { + production { + input("obj.gold_bar") + input("obj.sapphire") + statReq("stat.crafting", 22) + xp(550) + output("obj.sapphire_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_sapphire_bracelet") { + production { + input("obj.gold_bar") + input("obj.sapphire") + statReq("stat.crafting", 23) + xp(600) + output("obj.jewl_sapphire_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_sapphire_amulet") { + production { + input("obj.gold_bar") + input("obj.sapphire") + statReq("stat.crafting", 24) + xp(650) + output("obj.unstrung_sapphire_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_emerald_ring") { + production { + input("obj.gold_bar") + input("obj.emerald") + statReq("stat.crafting", 27) + xp(550) + output("obj.emerald_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_emerald_necklace") { + production { + input("obj.gold_bar") + input("obj.emerald") + statReq("stat.crafting", 29) + xp(600) + output("obj.emerald_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_emerald_bracelet") { + production { + input("obj.gold_bar") + input("obj.emerald") + statReq("stat.crafting", 30) + xp(650) + output("obj.jewl_emerald_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_emerald_amulet") { + production { + input("obj.gold_bar") + input("obj.emerald") + statReq("stat.crafting", 31) + xp(700) + output("obj.unstrung_emerald_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_ruby_ring") { + production { + input("obj.gold_bar") + input("obj.ruby") + statReq("stat.crafting", 34) + xp(700) + output("obj.ruby_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_ruby_necklace") { + production { + input("obj.gold_bar") + input("obj.ruby") + statReq("stat.crafting", 40) + xp(750) + output("obj.ruby_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_ruby_bracelet") { + production { + input("obj.gold_bar") + input("obj.ruby") + statReq("stat.crafting", 42) + xp(800) + output("obj.jewl_ruby_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_ruby_amulet") { + production { + input("obj.gold_bar") + input("obj.ruby") + statReq("stat.crafting", 50) + xp(850) + output("obj.unstrung_ruby_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_diamond_ring") { + production { + input("obj.gold_bar") + input("obj.diamond") + statReq("stat.crafting", 43) + xp(850) + output("obj.diamond_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_diamond_necklace") { + production { + input("obj.gold_bar") + input("obj.diamond") + statReq("stat.crafting", 56) + xp(900) + output("obj.diamond_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_diamond_bracelet") { + production { + input("obj.gold_bar") + input("obj.diamond") + statReq("stat.crafting", 58) + xp(950) + output("obj.jewl_diamond_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_diamond_amulet") { + production { + input("obj.gold_bar") + input("obj.diamond") + statReq("stat.crafting", 70) + xp(1000) + output("obj.unstrung_diamond_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_dragonstone_ring") { + production { + input("obj.gold_bar") + input("obj.dragonstone") + statReq("stat.crafting", 55) + xp(1000) + output("obj.dragonstone_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_dragonstone_necklace") { + production { + input("obj.gold_bar") + input("obj.dragonstone") + statReq("stat.crafting", 72) + xp(1050) + output("obj.dragonstone_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_dragonstone_bracelet") { + production { + input("obj.gold_bar") + input("obj.dragonstone") + statReq("stat.crafting", 74) + xp(1100) + output("obj.jewl_dragonstone_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_dragonstone_amulet") { + production { + input("obj.gold_bar") + input("obj.dragonstone") + statReq("stat.crafting", 80) + xp(1500) + output("obj.unstrung_dragonstone_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_onyx_ring") { + production { + input("obj.gold_bar") + input("obj.onyx") + statReq("stat.crafting", 67) + xp(1150) + output("obj.onyx_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_onyx_necklace") { + production { + input("obj.gold_bar") + input("obj.onyx") + statReq("stat.crafting", 82) + xp(1200) + output("obj.onyx_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_onyx_bracelet") { + production { + input("obj.gold_bar") + input("obj.onyx") + statReq("stat.crafting", 84) + xp(1250) + output("obj.jewl_onyx_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_onyx_amulet") { + production { + input("obj.gold_bar") + input("obj.onyx") + statReq("stat.crafting", 90) + xp(1650) + output("obj.unstrung_onyx_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_zenyte_ring") { + production { + input("obj.gold_bar") + input("obj.zenyte") + statReq("stat.crafting", 89) + xp(1500) + output("obj.zenyte_ring") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + } + row("dbrow.crafting_zenyte_necklace") { + production { + input("obj.gold_bar") + input("obj.zenyte") + statReq("stat.crafting", 92) + xp(1650) + output("obj.zenyte_necklace") + } + columnRSCM(COL_TOOL, "obj.necklace_mould") + } + row("dbrow.crafting_zenyte_bracelet") { + production { + input("obj.gold_bar") + input("obj.zenyte") + statReq("stat.crafting", 95) + xp(1800) + output("obj.zenyte_bracelet") + } + columnRSCM(COL_TOOL, "obj.jewl_bracelet_mould") + } + row("dbrow.crafting_zenyte_amulet") { + production { + input("obj.gold_bar") + input("obj.zenyte") + statReq("stat.crafting", 98) + xp(2000) + output("obj.unstrung_zenyte_amulet") + } + columnRSCM(COL_TOOL, "obj.amulet_mould") + } + + row("dbrow.crafting_slayer_ring") { + production { + input("obj.gold_bar") + input("obj.slayer_gem") + statReq("stat.crafting", 75) + xp(150) + output("obj.slayer_ring_8") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + column(COL_UNLOCK_VARBIT, "varbit.slayer_ring_unlocked", VarbitCompare.GTE.id, 1) + } + row("dbrow.crafting_slayer_ring_eternal") { + production { + input("obj.gold_bar") + input("obj.slayer_eternal_gem") + statReq("stat.crafting", 75) + xp(150) + output("obj.slayer_ring_eternal") + } + columnRSCM(COL_TOOL, "obj.ring_mould") + column(COL_UNLOCK_VARBIT, "varbit.slayer_ring_unlocked", VarbitCompare.GTE.id, 1) + } + } + } + +} + +/** + * A production table whose rows are grouped into [CraftingSectionScope]s. A section stamps every + * row with its name ([Crafting.COL_SECTION]) and, when given one, a default `category` that a row + * may still override inside its `production {}` block. + */ +private fun DBTableBuilder.craftingColumns() { + column("section", Crafting.COL_SECTION, VarType.STRING) + column("success_low", Crafting.COL_SUCCESS_LOW, VarType.INT) + column("success_high", Crafting.COL_SUCCESS_HIGH, VarType.INT) + column("ticks", Crafting.COL_TICKS, VarType.INT) + column("fail_xp", Crafting.COL_FAIL_XP, VarType.INT) + column("fail_item", Crafting.COL_FAIL_ITEM, VarType.OBJ) + column("anim", Crafting.COL_ANIM, VarType.STRING) + column("spotanim", Crafting.COL_SPOTANIM, VarType.STRING) + column("triggers", Crafting.COL_TRIGGERS, VarType.OBJ) + column("xp_extra", Crafting.COL_XP_EXTRA, VarType.STAT, VarType.INT) + column("cost", Crafting.COL_COST, VarType.INT) + column("tool", Crafting.COL_TOOL, VarType.OBJ) + column("sound", Crafting.COL_SOUND, VarType.STRING) + column("message", Crafting.COL_SPAM_MESSAGE, VarType.STRING) + column("action_name", Crafting.COL_ACTION_NAME, VarType.STRING) + column("confirm_title", Crafting.COL_CONFIRM_TITLE, VarType.STRING) + column("confirm_warning", Crafting.COL_CONFIRM_WARNING, VarType.STRING) + column("result_dialogue", Crafting.COL_RESULT_DIALOGUE, VarType.STRING) + column("quest_req", Crafting.COL_QUEST_REQ, VarType.STRING, VarType.INT) + column("unlock_varbit", Crafting.COL_UNLOCK_VARBIT, VarType.STRING, VarType.INT, VarType.INT) + column("locked_message", Crafting.COL_LOCKED_MESSAGE, VarType.STRING) +} + +private fun craftingTable( + tableId: String, + extraColumns: DBTableBuilder.() -> Unit = { craftingColumns() }, + block: CraftingTableScope.() -> Unit, +) = productionTable(tableId, serverOnly = true, extraColumns = extraColumns) { + CraftingTableScope(this).block() +} + +private class CraftingTableScope(private val table: ProductionTableScope) { + fun section(name: String, category: String? = null, block: CraftingSectionScope.() -> Unit) { + CraftingSectionScope(table, name, category).block() + } +} + +private class CraftingSectionScope( + private val table: ProductionTableScope, + private val section: String, + private val category: String?, +) { + /** + * The section/category columns are written before [block] runs, so a row's own + * `category(...)` (needlework's hide families) still takes priority. + * Later writes replace earlier ones for the same column. + */ + fun row(rowId: String, block: ProductionTableRowScope.() -> Unit) { + table.row(rowId) { + column(Crafting.COL_SECTION, section) + category?.let { column(ProductionColumns.COL_CATEGORY, it) } + block() + } + } +} + +/** How a recipe's `quest_req` column reads a quest's state. Mirrors the quest manager's own enum. */ +enum class QuestReq(val id: Int) { + Completed(0), + InProgress(1), + NotCompleted(2); + + companion object { + fun of(id: Int?): QuestReq? = entries.firstOrNull { it.id == id } + } +} + +/** How a recipe's `unlock_varbit` column compares the varbit against its value. */ +enum class VarbitCompare(val id: Int) { + EQ(0), //Equals + NE(1), //Not Equals + LT(2), //Less Than + LTE(3), //Less Than (or) Equal + GT(4), // Greater Than + GTE(5); //Greater Than (or) Equal + + fun passes(actual: Int, expected: Int): Boolean = + when (this) { + EQ -> actual == expected + NE -> actual != expected + LT -> actual < expected + LTE -> actual <= expected + GT -> actual > expected + GTE -> actual >= expected + } + + companion object { + fun of(id: Int?): VarbitCompare? = entries.firstOrNull { it.id == id } + } +} From 276cdff96600058971eef14c27c4aae900a0d659 Mon Sep 17 00:00:00 2001 From: Seelad Date: Wed, 29 Jul 2026 12:30:18 -0400 Subject: [PATCH 2/5] Fixed 0 tick recipes --- .../org/rsmod/content/skills/crafting/CraftingProduct.kt | 2 +- .../org/rsmod/content/skills/crafting/CraftingSection.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt index 26b15dd64..3b7c94033 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingProduct.kt @@ -153,7 +153,7 @@ fun craftingProduct( extraReqs = extraReqs, extraXp = xpExtra, triggers = triggers.map { it.internalName }, - ticks = ticks.ifEmpty { listOf(section.ticks) }.map { it.coerceAtLeast(1) }, + ticks = ticks.ifEmpty { listOf(section.ticks) }.map { it.coerceAtLeast(0) }, anims = CraftingGamevals.filterResolvable(anims.ifEmpty { listOfNotNull(section.anim) }), imcandoAnim = CraftingGamevals.optional(section.imcandoAnim), locAnim = CraftingGamevals.optional(locAnim ?: section.locAnim), diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt index 097d2d06a..326961fa0 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt @@ -203,8 +203,7 @@ enum class CraftingSection( id = "Limestone", verb = "cut", actionType = SkillingActionType.CUT, - ticks = 1, - mode = CraftingMode.INSTANT, + ticks = 0, //same as mode = CraftingMode.INSTANT anim = CraftingConstants.ANIM_LIMESTONE_CUT, sound = CraftingConstants.SOUND_GEM_CUTTING, tools = listOf(CraftingConstants.CHISEL), From 998801aeca10b03bc4aa443bf8eb6e654c8e14fe Mon Sep 17 00:00:00 2001 From: Seelad Date: Mon, 3 Aug 2026 15:12:00 -0400 Subject: [PATCH 3/5] Fixed battlestaff spam message (It has none) --- .../kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt index 326961fa0..adb7ab51a 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt @@ -233,7 +233,6 @@ enum class CraftingSection( anim = CraftingConstants.ANIM_BATTLESTAFF, sound = CraftingConstants.SOUND_BATTLESTAFF_ATTACH, actionName = { "make a ${it.output}" }, - successMessage = { "You attach the orb to the staff, making a ${it.output}." }, ), AMULET_STRINGING( From dc45901dc02c39ee178c241414bb81ef50d01ba5 Mon Sep 17 00:00:00 2001 From: Seelad Date: Mon, 3 Aug 2026 23:30:15 -0400 Subject: [PATCH 4/5] Crafting cleanup and consistency --- .../content/skills/crafting/CraftingGuildDoor.kt | 2 +- .../skills/crafting/CraftingGuildRequirements.kt | 5 +---- .../content/skills/crafting/CraftingSection.kt | 9 ++++++--- .../content/skills/crafting/CraftingWorker.kt | 10 ++++------ .../crafting/interfaces/GoldCraftingInterface.kt | 4 +--- .../skills/crafting/interfaces/TannerInterface.kt | 3 +++ .../crafting/npcs/CraftingGuildMasterCrafter.kt | 14 +++++++------- .../content/skills/crafting/npcs/EodanScript.kt | 9 ++++----- .../skills/crafting/npcs/LeatherTannerScript.kt | 11 ++++++----- .../content/skills/crafting/npcs/MaryScript.kt | 7 ++++--- .../content/skills/crafting/npcs/SbottScript.kt | 14 ++++++-------- .../skills/crafting/scripts/HeldCraftingScript.kt | 1 - .../skills/crafting/util/CraftingConstants.kt | 3 +++ 13 files changed, 46 insertions(+), 46 deletions(-) diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt index 0f2c327ba..5e98c75a3 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildDoor.kt @@ -41,7 +41,7 @@ class CraftingGuildDoor @Inject constructor(private val locRepo: LocRepository) type = MASTER_CRAFTER_NPC, mesanim = neutral, text = "Sorry, only experienced crafters are allowed in here. You must be " + - "level 40 or above to enter.", + "level $GUILD_ENTRY_LEVEL or above to enter.", ) } diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt index b3f77a5e8..7c791fd02 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingGuildRequirements.kt @@ -1,12 +1,9 @@ package org.rsmod.content.skills.crafting -import dev.openrune.ServerCacheManager -import org.rsmod.api.player.back import org.rsmod.api.player.stat.baseCraftingLvl import org.rsmod.api.player.vars.boolVarBit import org.rsmod.content.skills.crafting.util.CraftingConstants import org.rsmod.game.entity.Player -import org.rsmod.game.inv.InvObj private val Player.faladorHardDiaryComplete by boolVarBit("varbit.falador_diary_hard_complete") private val Player.faladorEliteDiaryComplete by boolVarBit("varbit.falador_diary_elite_complete") @@ -19,7 +16,7 @@ internal fun Player.wearingCraftingApron(): Boolean = CraftingConstants.GUILD_AP internal fun Player.ownsCraftingSkillcape(): Boolean = CraftingConstants.CRAFTING_SKILLCAPES.any { it in inv || it in worn } -internal fun Player.ownsCraftingHood(): Boolean = "obj.skillcape_crafting_hood" in inv || "obj.skillcape_crafting_hood" in worn +internal fun Player.ownsCraftingHood(): Boolean = CraftingConstants.CRAFTING_HOOD in inv || CraftingConstants.CRAFTING_HOOD in worn internal fun Player.hasGuildEntryOutfit(): Boolean = wearingCraftingApron() || wearingCraftingSkillcape() || wearingMaxCape() diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt index adb7ab51a..d8c7a5d48 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt @@ -67,7 +67,6 @@ enum class CraftingSection( locAnim = CraftingConstants.LOC_ANIM_WEAVING, sound = CraftingConstants.SOUND_WEAVING, actionName = { "weave ${it.output}" }, - successMessage = { null }, emptyMenuMessage = { "You either don't have the required items or don't have enough of them to weave " + "anything at this loom." @@ -169,7 +168,6 @@ enum class CraftingSection( anim = CraftingConstants.ANIM_KNIFE_CUTTING, tools = listOf(CraftingConstants.KNIFE), actionName = { "make a ${it.output}" }, - successMessage = { null }, ), GEMS( @@ -323,5 +321,10 @@ internal fun String.plural(): String = when { else -> "${this}s" } +private const val VOWELS = "aeiou" + /** Prepends the indefinite article, giving an orb or a beer glass. */ -internal fun String.withArticle(): String = if (firstOrNull()?.lowercaseChar() in setOf('a', 'e', 'i', 'o', 'u')) "an $this" else "a $this" +internal fun String.withArticle(): String { + val first = firstOrNull()?.lowercaseChar() + return if (first != null && first in VOWELS) "an $this" else "a $this" +} diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt index a6cbca1a9..79eb0ee90 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt @@ -471,21 +471,19 @@ private suspend fun ProtectedAccess.openSkillMultiForProducts( shown: List, onSelect: suspend (output: String, amount: Int) -> Unit, ) { - val entries = shown.map { - product -> SkillMultiEntry(product.output, product.inputs) - } + val entries = shown.map { product -> SkillMultiEntry(product.output, product.inputs) } + val byOutput = shown.associateBy { it.output } openSkillMulti( SkillMultiConfig( verb = section.verb, actionType = section.actionType, entries = entries, maxCountProvider = { inventory, entry -> - val product = shown.firstOrNull { it.output == entry.internal } + val product = byOutput[entry.internal] product?.maxCraftable { inventory.count(it) }?.coerceAtLeast(1) ?: 1 }, ), - ) - { + ) { selection -> onSelect(selection.entry.internal, selection.amount) } } diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt index bc3bd9e7e..5ba75c4a0 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/GoldCraftingInterface.kt @@ -43,9 +43,7 @@ fun ProtectedAccess.openGoldCrafting() { ifOpenMainModal(INTERFACE_GOLD_CRAFTING) for (slot in goldSlots) { ifSetEvents(slot.component, -1..-1, IfEvent.Op1) - if (CraftingGamevals.exists(slot.component)) { - ifSetHide(slot.component, hide = !slotUnlocked(slot)) - } + ifSetHide(slot.component, hide = !slotUnlocked(slot)) } } diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt index 8691e6ec5..6c03f7862 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/interfaces/TannerInterface.kt @@ -224,6 +224,9 @@ internal val tannableHideObjs: Set by lazy { TANNER_SLOTS.map { it.input /** The leather objs tanning produces. */ internal val tannedLeatherObjs: Set by lazy { TANNER_SLOTS.map { it.output }.toSet() } +/** How many hides the player is carrying that a tanner will take. */ +internal fun ProtectedAccess.heldTannableHides(): Int = tannableHideObjs.sumOf { inv.count(it) } + /** Coin count worded for chat. */ private fun coins(amount: Int): String = if (amount == 1) "1 coin" else "$amount coins" diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt index 787315352..467ffa3ab 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/CraftingGuildMasterCrafter.kt @@ -1,15 +1,15 @@ package org.rsmod.content.skills.crafting.npcs -import jakarta.inject.Inject import org.rsmod.api.player.dialogue.Dialogue import org.rsmod.api.player.stat.baseCraftingLvl import org.rsmod.api.script.onOpNpc1 import org.rsmod.content.skills.crafting.ownsCraftingHood import org.rsmod.content.skills.crafting.ownsCraftingSkillcape +import org.rsmod.content.skills.crafting.util.CraftingConstants import org.rsmod.plugin.scripts.PluginScript import org.rsmod.plugin.scripts.ScriptContext -class CraftingGuildMasterCrafter @Inject constructor() : PluginScript() { +class CraftingGuildMasterCrafter : PluginScript() { override fun ScriptContext.startup() { onOpNpc1(MASTER_CRAFTER) { startDialogue(it.npc) { caped() } } @@ -18,14 +18,14 @@ class CraftingGuildMasterCrafter @Inject constructor() : PluginScript() { } private suspend fun Dialogue.caped() { - if (player.baseCraftingLvl >= 99) { - capedAt99() + if (player.baseCraftingLvl >= CraftingConstants.MAX_CRAFTING_LEVEL) { + capedAtMaxLevel() } else { - capedBelow99() + capedBelowMaxLevel() } } - private suspend fun Dialogue.capedBelow99() { + private suspend fun Dialogue.capedBelowMaxLevel() { chatNpc( happy, "Hello, and welcome to the Crafting Guild. Accomplished crafters from all over the land " + @@ -57,7 +57,7 @@ class CraftingGuildMasterCrafter @Inject constructor() : PluginScript() { } } - private suspend fun Dialogue.capedAt99() { + private suspend fun Dialogue.capedAtMaxLevel() { chatNpc( happy, "Hello, and welcome to the Crafting Guild. Accomplished crafters from all over the land " + diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt index a25c6808f..89be2e03f 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/EodanScript.kt @@ -22,8 +22,7 @@ class EodanScript : PluginScript() { } onOpNpc1(TANNER_EODAN) { greet(it.npc) } onOpNpc3(TANNER_EODAN) { openTanner(eodanPrices()) } - onOpNpcU(TANNER_EODAN) { event -> usedItemOnEodan(event.npc, event.objType.internalName) - } + onOpNpcU(TANNER_EODAN) { usedItemOnEodan(it.npc, it.objType.internalName) } } private suspend fun ProtectedAccess.greet(npc: Npc) { @@ -106,10 +105,10 @@ class EodanScript : PluginScript() { ) } - private suspend fun ProtectedAccess.usedItemOnEodan(npc: Npc, obj: String?) { + private suspend fun ProtectedAccess.usedItemOnEodan(npc: Npc, obj: String) { when { - obj != null && obj in tannableHideObjs -> openTanner(eodanPrices()) - obj != null && obj in tannedLeatherObjs -> + obj in tannableHideObjs -> openTanner(eodanPrices()) + obj in tannedLeatherObjs -> startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } } diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt index 500f9b891..ea97992da 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/LeatherTannerScript.kt @@ -7,6 +7,7 @@ import org.rsmod.api.script.onOpNpc2 import org.rsmod.api.script.onOpNpc3 import org.rsmod.api.script.onOpNpcU import org.rsmod.content.skills.crafting.interfaces.TannerPrices +import org.rsmod.content.skills.crafting.interfaces.heldTannableHides import org.rsmod.content.skills.crafting.interfaces.openTanner import org.rsmod.content.skills.crafting.interfaces.tannableHideObjs import org.rsmod.content.skills.crafting.interfaces.tannedLeatherObjs @@ -38,15 +39,16 @@ class LeatherTannerScript : PluginScript() { onOpNpc1(npc) { flow.greet(this, it.npc, prices) } onOpNpc2(npc) { openTanner(prices) } onOpNpc3(npc) { openTanner(prices) } - onOpNpcU(npc) { event -> usedItemOnTanner(event.npc, event.objType?.internalName, prices) } + onOpNpcU(npc) { usedItemOnTanner(it.npc, it.objType.internalName, prices) } } } /** Item-on-tanner interaction */ - private suspend fun ProtectedAccess.usedItemOnTanner(npc: Npc, obj: String?, prices: TannerPrices) { + private suspend fun ProtectedAccess.usedItemOnTanner(npc: Npc, obj: String, prices: TannerPrices) { when { - obj != null && obj in tannableHideObjs -> openTanner(prices) - obj != null && obj in tannedLeatherObjs -> startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } + obj in tannableHideObjs -> openTanner(prices) + obj in tannedLeatherObjs -> + startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } } } @@ -149,7 +151,6 @@ private suspend fun Dialogue.offerTanning(hides: Int, prices: TannerPrices) { } } -private fun ProtectedAccess.heldTannableHides(): Int = tannableHideObjs.sumOf { inv.count(it) } private fun ProtectedAccess.sirMadam(): String = if (isBodyTypeA()) "sir" else "madam" /** Ellis, the Al Kharid tanner. */ diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt index 0aafd434a..dd12c8ae3 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/MaryScript.kt @@ -100,11 +100,12 @@ class MaryScript : PluginScript() { } } - private suspend fun ProtectedAccess.usedItemOnMary(npc: Npc, obj: String?) { + private suspend fun ProtectedAccess.usedItemOnMary(npc: Npc, obj: String) { when { !tansForPlayer() -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } - obj != null && obj in tannableHideObjs -> openTanner(MARY_PRICES) - obj != null && obj in tannedLeatherObjs -> startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } + obj in tannableHideObjs -> openTanner(MARY_PRICES) + obj in tannedLeatherObjs -> + startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } } } diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt index 532bbdbdb..b412d4f87 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/npcs/SbottScript.kt @@ -6,6 +6,7 @@ import org.rsmod.api.script.onOpNpc1 import org.rsmod.api.script.onOpNpc3 import org.rsmod.api.script.onOpNpcU import org.rsmod.content.skills.crafting.interfaces.TannerPrices +import org.rsmod.content.skills.crafting.interfaces.heldTannableHides import org.rsmod.content.skills.crafting.interfaces.openTanner import org.rsmod.content.skills.crafting.interfaces.tannableHideObjs import org.rsmod.content.skills.crafting.interfaces.tannedLeatherObjs @@ -23,8 +24,7 @@ class SbottScript : PluginScript() { } onOpNpc1(TANNER_SBOTT) { greet(it.npc) } onOpNpc3(TANNER_SBOTT) { openTanner(SBOTT_PRICES) } - onOpNpcU(TANNER_SBOTT) { event -> usedItemOnSbott(event.npc, event.objType.internalName) - } + onOpNpcU(TANNER_SBOTT) { usedItemOnSbott(it.npc, it.objType.internalName) } } private suspend fun ProtectedAccess.greet(npc: Npc) { @@ -96,17 +96,15 @@ class SbottScript : PluginScript() { chatNpc(neutral, "Fair enough. I can't tan what you don't bring me.") } - private suspend fun ProtectedAccess.usedItemOnSbott(npc: Npc, obj: String?) { + private suspend fun ProtectedAccess.usedItemOnSbott(npc: Npc, obj: String) { when { - obj != null && obj in tannableHideObjs -> openTanner(SBOTT_PRICES) - obj != null && obj in tannedLeatherObjs -> startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } + obj in tannableHideObjs -> openTanner(SBOTT_PRICES) + obj in tannedLeatherObjs -> + startDialogue(npc) { chatNpc(neutral, "Er... I have no use for that, I make the stuff!") } else -> startDialogue(npc) { chatNpc(neutral, "Er... Thanks, but no thanks!") } } } - private fun ProtectedAccess.heldTannableHides(): Int = - tannableHideObjs.sumOf { inv.count(it) } - /** Options for the top level Yes, Why and No choice. Only used internally. */ private enum class OfferChoice { Yes, Why, No } } diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt index 718b24c4e..8b2895070 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/scripts/HeldCraftingScript.kt @@ -2,7 +2,6 @@ package org.rsmod.content.skills.crafting.scripts import org.rsmod.api.table.crafting.CraftingHandRow import org.rsmod.content.skills.crafting.CraftingMode -import org.rsmod.content.skills.crafting.CraftingProduct import org.rsmod.content.skills.crafting.registerHeldCrafting import org.rsmod.content.skills.crafting.toCraftingProduct import org.rsmod.plugin.scripts.PluginScript diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt index c1cf12ade..1ba489ecf 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/util/CraftingConstants.kt @@ -152,6 +152,9 @@ object CraftingConstants { val CRAFTING_SKILLCAPES: Set = setOf("obj.skillcape_crafting", "obj.skillcape_crafting_trimmed") + /** The crafting cape's hood. */ + const val CRAFTING_HOOD = "obj.skillcape_crafting_hood" + /** Aprons that get a player through the guild door. */ val GUILD_APRONS: Set = setOf("obj.brown_apron", "obj.golden_apron") From 276230aaf587a6973209f1eb36c136a1b9628204 Mon Sep 17 00:00:00 2001 From: Seelad Date: Tue, 4 Aug 2026 00:10:14 -0400 Subject: [PATCH 5/5] Crafting: Fixed synth timing for facilities and glassblowing to be accurate. --- .../org/rsmod/content/skills/crafting/CraftingSection.kt | 8 ++++++++ .../org/rsmod/content/skills/crafting/CraftingWorker.kt | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt index d8c7a5d48..d3214df1f 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingSection.kt @@ -23,6 +23,8 @@ enum class CraftingSection( val ticks: Int, /** Whether the first craft of a batch runs a tick short, as most crafting does. */ val shortensFirstCraft: Boolean = true, + /** Whether [sound] replays every craft instead of only when the animation restarts. */ + val repeatsSoundPerCraft: Boolean = false, val mode: CraftingMode = CraftingMode.MENU, val anim: String? = null, val imcandoAnim: String? = null, @@ -51,6 +53,7 @@ enum class CraftingSection( anim = CraftingConstants.ANIM_SPINNING, locAnim = CraftingConstants.LOC_ANIM_SPINNING, sound = CraftingConstants.SOUND_SPINNING, + repeatsSoundPerCraft = true, actionName = { "spin ${it.output}" }, successMessage = { "You spin the ${it.input} into ${it.output}." }, emptyMenuMessage = { "You don't have anything suitable to spin at this spinning wheel." }, @@ -66,6 +69,7 @@ enum class CraftingSection( anim = CraftingConstants.ANIM_WEAVING, locAnim = CraftingConstants.LOC_ANIM_WEAVING, sound = CraftingConstants.SOUND_WEAVING, + repeatsSoundPerCraft = true, actionName = { "weave ${it.output}" }, emptyMenuMessage = { "You either don't have the required items or don't have enough of them to weave " + @@ -81,6 +85,7 @@ enum class CraftingSection( anim = CraftingConstants.ANIM_POTTERY_WHEEL, locAnim = CraftingConstants.LOC_ANIM_POTTERY_WHEEL, sound = CraftingConstants.SOUND_POTTERY_WHEEL, + repeatsSoundPerCraft = true, actionName = { "make ${it.output}" }, successMessage = { "You make the clay into ${it.output.removePrefix("unfired ").withArticle()}." }, // Strips the "unfired " prefix off the output name emptyMenuMessage = { "You don't have anything suitable to craft with." }, @@ -93,6 +98,7 @@ enum class CraftingSection( ticks = 7, anim = CraftingConstants.ANIM_POTTERY_OVEN, sound = CraftingConstants.SOUND_FURNACE, + repeatsSoundPerCraft = true, actionName = { "fire ${it.output}" }, startMessage = { "You put the ${it.output} in the oven." }, successMessage = { "You remove the ${it.output} from the oven." }, @@ -217,6 +223,7 @@ enum class CraftingSection( ticks = 3, anim = CraftingConstants.ANIM_GLASSBLOWING, sound = CraftingConstants.SOUND_GLASSBLOWING, + repeatsSoundPerCraft = true, tools = listOf(CraftingConstants.GLASSBLOWING_PIPE), actionName = { "make ${it.output}" }, successMessage = { "You make ${it.output.withArticle()}." }, @@ -292,6 +299,7 @@ enum class CraftingSection( ticks = 3, anim = CraftingConstants.ANIM_SAND_PIT, sound = CraftingConstants.SOUND_SAND_BUCKET, + repeatsSoundPerCraft = true, actionName = { "fill a bucket with sand" }, successMessage = { "You fill the bucket with sand." }, ), diff --git a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt index 79eb0ee90..20782c374 100644 --- a/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt +++ b/content/skills/crafting/src/main/kotlin/org/rsmod/content/skills/crafting/CraftingWorker.kt @@ -125,9 +125,9 @@ private fun ProtectedAccess.beginCycle( facility: BoundLocInfo?, cycle: Int = 0, ) { - // The client drops an animation already playing, so a sound fired every craft would outpace it. + // The client drops an animation already playing, so a sound fired every craft can outpace it. val restarted = startCraftAnim(product, cycle) - if (restarted) { + if (restarted || product.section.repeatsSoundPerCraft) { product.sound?.let { soundSynth(it) } } product.spotanimAt(cycle)?.let { spotanim(it) }