Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .data/raw-cache/server/loc/loc.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
24 changes: 24 additions & 0 deletions .data/raw-cache/server/npcs.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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<If1Button> {
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) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions content/skills/crafting/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<String, LinkedHashSet<CraftingProduct>>()

/** 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<CraftingProduct> = byOutput[output]?.toList() ?: emptyList()

/** Every obj the module can craft. */
fun outputs(): Set<String> = 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<CraftingMaterial>? {
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<CraftingMaterial> {
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<CraftingProduct>,
combine: suspend ProtectedAccess.(CraftingProduct) -> Unit = { craftInstantly(it) },
) {
val registrations = LinkedHashMap<Set<String>, 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<CraftingProduct>()
}

/** Every click pair that should start this recipe. */
private fun CraftingProduct.clickPairs(): List<Pair<String, String>> {
val ingredients = triggers.ifEmpty { inputs.map { it.internal } }.distinct()
val pairs = mutableListOf<Pair<String, String>>()
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<String, String> = 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<CraftingProduct>,
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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}

}
Loading