From dd1cfbba515dd4310e2d56ae4cd28834456978ee Mon Sep 17 00:00:00 2001 From: Sam Marder Date: Sat, 30 May 2026 14:13:28 +0000 Subject: [PATCH] Combo runes are now consumed before standard elemental runes if both are present in inventory 1 combo rune can count for 2 elemental runes if the spell requires both --- .../global/skill/magic/SpellListener.kt | 9 +- .../content/global/skill/magic/SpellUtils.kt | 103 ++++++++---------- .../skill/magic/modern/ModernListeners.kt | 11 +- .../node/entity/combat/spell/MagicSpell.java | 60 ++-------- 4 files changed, 63 insertions(+), 120 deletions(-) diff --git a/Server/src/main/content/global/skill/magic/SpellListener.kt b/Server/src/main/content/global/skill/magic/SpellListener.kt index 64383b0a7..e938f3ef0 100644 --- a/Server/src/main/content/global/skill/magic/SpellListener.kt +++ b/Server/src/main/content/global/skill/magic/SpellListener.kt @@ -47,11 +47,10 @@ abstract class SpellListener(val bookName: String) : Listener { player.sendMessage("You need a magic level of $magicLevel to cast this spell.") throw IllegalStateException() } - for(rune in runes){ - if(!SpellUtils.hasRune(player,rune)){ - player.sendMessage("You don't have enough ${rune.definition.name.lowercase()}s to cast this spell.") - throw IllegalStateException() - } + val missing = SpellUtils.hasRunes(player, runes) + if (missing != null) { + player.sendMessage("You don't have enough ${missing.definition.name.lowercase()}s to cast this spell.") + throw IllegalStateException() } for(item in specialEquipment){ if(!player.equipment.contains(item,1)){ diff --git a/Server/src/main/content/global/skill/magic/SpellUtils.kt b/Server/src/main/content/global/skill/magic/SpellUtils.kt index beafbf64d..57e8b4c6d 100644 --- a/Server/src/main/content/global/skill/magic/SpellUtils.kt +++ b/Server/src/main/content/global/skill/magic/SpellUtils.kt @@ -3,9 +3,9 @@ package content.global.skill.magic import core.game.node.entity.combat.spell.CombinationRune import core.game.node.entity.combat.spell.MagicStaff import core.game.node.entity.combat.spell.Runes -import core.game.node.entity.npc.NPC import core.game.node.entity.player.Player import core.game.node.item.Item +import kotlin.math.min object SpellUtils { /** @@ -32,70 +32,57 @@ object SpellUtils { return false } - fun hasRune(p:Player,rune:Item):Boolean{ - val removeItems = p.getAttribute("spell:runes",ArrayList()) - if(usingStaff(p,rune.id)) return true - if(p.inventory.containsItem(rune)){ - removeItems.add(rune) - p.setAttribute("spell:runes",removeItems) + /** + * Validates if the player has the necessary runes to cast a spell. + * + * If the player is able to cast the spell, the "spell:runes" attribute will be set to the list of items that should + * be removed from the player's inventory after successfully casting the spell. This accounts for staves and + * combination runes. + * + * @param p The player casting the spell + * @param runes The runes and other items required to cast the spell + * @return null if the player can cast the spell or an Item representing at least one of the runes the player is missing + */ + @JvmStatic + fun hasRunes(p: Player, runes: Array): Item? { + val cost = HashMap() + // `runes` are mostly actual runes but occasionally other items like staves or unpowered orbs + for (rune in runes) { + if (usingStaff(p, rune.id)) continue + cost[rune.id] = cost.getOrDefault(rune.id, 0) + rune.amount } - val baseAmt = p.inventory.getAmount(rune.id) - var amtRemaining = rune.amount - baseAmt - val possibleComboRunes = CombinationRune.eligibleFor(Runes.forId(rune.id)) - for (r in possibleComboRunes) { - if (p.inventory.containsItem(Item(r.id)) && amtRemaining > 0) { - val amt = p.inventory.getAmount(r.id) - if (amtRemaining <= amt) { - removeItems.add(Item(r.id,amtRemaining)) - amtRemaining = 0 - break - } - removeItems.add(Item(r.id,p.inventory.getAmount(r.id))) - amtRemaining -= p.inventory.getAmount(r.id) + val toRemove = ArrayList() + + // Combination runes are used before elemental runes. + // https://runescape.wiki/w/Runecrafting?oldid=2618332#Function_and_Usage_of_Combination_Runes: + for (combo in CombinationRune.values()) { + val available = p.inventory.getAmount(combo.id) + val maxUsage = combo.types.mapNotNull { cost[it.id] }.maxOrNull() ?: 0 + if (maxUsage > 0 && available >= 0) { + val usage = min(maxUsage, available) + + toRemove.add(Item(combo.id, usage)) + // Even if a spell uses both parts of a combo rune, it should only consume a single rune. For example, + // a spell that requires an air rune and an earth rune should only consume a single dust rune. + // https://youtu.be/9gAiqEmF-Hc?t=67 + combo.types.forEach { cost[it.id] = cost.getOrDefault(it.id, 0) - usage } } } - p.setAttribute("spell:runes",removeItems) - return amtRemaining <= 0 - } - fun hasRune(p: Player, item: Item, toRemove: MutableList, message: Boolean): Boolean { - if (!usingStaff(p, item.id)) { - val hasBaseRune = p.inventory.contains(item.id, item.amount) - if (!hasBaseRune) { - val baseAmt = p.inventory.getAmount(item.id) - if (baseAmt > 0) { - toRemove.add(Item(item.id, p.inventory.getAmount(item.id))) - } - var amtRemaining = item.amount - baseAmt - val possibleComboRunes = CombinationRune.eligibleFor(Runes.forId(item.id)) - for (r in possibleComboRunes) { - if (p.inventory.containsItem(Item(r.id)) && amtRemaining > 0) { - val amt = p.inventory.getAmount(r.id) - if (amtRemaining < amt) { - toRemove.add(Item(r.id, amtRemaining)) - amtRemaining = 0 - continue - } - amtRemaining -= p.inventory.getAmount(r.id) - toRemove.add(Item(r.id, p.inventory.getAmount(r.id))) - } - } - return if (amtRemaining <= 0) { - true - } else { - p.packetDispatch.sendMessage("You don't have enough " + item.name + "s to cast this spell.") - false - } + for ((runeId, amount) in cost) { + if (amount <= 0) continue + + val available = p.inventory.getAmount(runeId) + if (available < amount) { + return Item(runeId, amount) } - toRemove.add(item) - return true - } - return true - } - fun attackableNPC(npc: NPC): Boolean{ - return npc.definition.hasAction("attack") + toRemove.add(Item(runeId, amount)) + } + + p.setAttribute("spell:runes", toRemove) + return null } @JvmStatic diff --git a/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt b/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt index 914b06646..777beee18 100644 --- a/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt +++ b/Server/src/main/content/global/skill/magic/modern/ModernListeners.kt @@ -2,7 +2,7 @@ package content.global.skill.magic.modern import content.data.Quests import content.global.skill.magic.SpellListener -import content.global.skill.magic.SpellUtils.hasRune +import content.global.skill.magic.SpellUtils.hasRunes import content.global.skill.magic.TeleportMethod import content.global.skill.magic.homeTeleport import content.global.skill.magic.spellconsts.Modern @@ -323,11 +323,10 @@ class ModernListeners : SpellListener("modern"){ sendMessage(player, "You need a magic level of ${spell.level} to cast this spell.") return@queueScript stopExecuting(player) } - for (rune in spell.requiredRunes) { - if(!hasRune(player,rune)){ - sendMessage(player, "You don't have enough ${rune.name.lowercase()}s to cast this spell.") - return@queueScript stopExecuting(player) - } + val missing = hasRunes(player, spell.requiredRunes) + if (missing != null) { + sendMessage(player, "You don't have enough ${missing.name.lowercase()}s to cast this spell.") + return@queueScript stopExecuting(player) } visualizeSpell(player, CHARGE_ORB_ANIM, spell.graphics, spell.sound) removeRunes(player) diff --git a/Server/src/main/core/game/node/entity/combat/spell/MagicSpell.java b/Server/src/main/core/game/node/entity/combat/spell/MagicSpell.java index 896a3ec62..544372bba 100644 --- a/Server/src/main/core/game/node/entity/combat/spell/MagicSpell.java +++ b/Server/src/main/core/game/node/entity/combat/spell/MagicSpell.java @@ -1,5 +1,6 @@ package core.game.node.entity.combat.spell; +import content.global.skill.magic.SpellUtils; import core.game.event.SpellCastEvent; import core.game.node.Node; import core.game.node.entity.Entity; @@ -17,7 +18,6 @@ import core.plugin.Plugin; import core.tools.RandomFunction; import java.util.ArrayList; -import java.util.List; import static core.api.ContentAPIKt.playGlobalAudio; @@ -223,16 +223,17 @@ public abstract class MagicSpell implements Plugin { if (runes == null) { return true; } - List toRemove = new ArrayList<>(20); - for (Item item : runes) { - if (!hasRune(p, item, toRemove, message)) { - return false; + Item missing = SpellUtils.hasRunes(p, runes); + if (missing != null) { + if (message) { + p.getPacketDispatch().sendMessage("You don't have enough " + missing.getName() + "s to cast this spell."); } + return false; } if (remove) { - toRemove.forEach(i -> { - p.getInventory().remove(i); - }); + ArrayList toRemove = p.getAttribute("spell:runes", new ArrayList<>()); + toRemove.forEach(i -> p.getInventory().remove(i)); + p.removeAttribute("spell:runes"); } return true; } @@ -255,49 +256,6 @@ public abstract class MagicSpell implements Plugin { return true; } - /** - * Checks if the player has a rune to remove. - * @param p the player. - * @param item the item. - * @param toRemove the list of items to remove. - * @param message the message. - * @return {@code True} if so. - */ - public boolean hasRune(Player p, Item item, List toRemove, boolean message) { - if (!usingStaff(p, item.getId())) { - boolean hasBaseRune = p.getInventory().contains(item.getId(),item.getAmount()); - if(!hasBaseRune){ - int baseAmt = p.getInventory().getAmount(item.getId()); - if(baseAmt > 0){ - toRemove.add(new Item(item.getId(),p.getInventory().getAmount(item.getId()))); - } - int amtRemaining = item.getAmount() - baseAmt; - List possibleComboRunes = CombinationRune.eligibleFor(Runes.forId(item.getId())); - for(CombinationRune r : possibleComboRunes){ - if(p.getInventory().containsItem(new Item(r.id)) && amtRemaining > 0){ - int amt = p.getInventory().getAmount(r.id); - if(amtRemaining < amt){ - toRemove.add(new Item(r.id,amtRemaining)); - amtRemaining = 0; - continue; - } - amtRemaining -= p.getInventory().getAmount(r.id); - toRemove.add(new Item(r.id,p.getInventory().getAmount(r.id))); - } - } - if(amtRemaining <= 0){ - return true; - } else { - p.getPacketDispatch().sendMessage("You don't have enough " + item.getName() + "s to cast this spell."); - return false; - } - } - toRemove.add(item); - return true; - } - return true; - } - /** * Adds the experience for casting this spell. * @param entity The entity to reward with experience.