From 1fa4ab0bd2ea89cea4e0b9bf344f18e4ab4652d2 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 11:51:26 -0700 Subject: [PATCH 01/15] Fix undrinkable potions and incorrect potion item data The two dose strength potion had neither `heals` nor `excess`, so it was rejected by the guard in `Eating.consume` and could not be drunk at all. `antidote+_mix_2` decanted into `super_antipoison_mix_1`, turning the item into a different potion when drunk. The `extreme_magic` dose block had its `weight` and `examine` values in reverse order, so the four dose potion described itself as one dose. `weak_range_potion` was registered for an item named `weak_ranged_potion`, so the weak ranged potion had no effect. Literal ids are not validated at startup the way wildcard patterns are, so this registered silently. Nettle tea assigned run energy rather than adding to it, discarding whatever the player already had. --- data/skill/herblore/barbarian_mix.items.toml | 2 +- data/skill/herblore/potion.items.toml | 17 +++++++++-------- .../constitution/drink/DungeoneeringPotions.kt | 2 +- .../content/skill/constitution/drink/Tea.kt | 2 +- .../content/skill/constitution/drink/TeaTest.kt | 13 +++++++++++++ 5 files changed, 25 insertions(+), 11 deletions(-) diff --git a/data/skill/herblore/barbarian_mix.items.toml b/data/skill/herblore/barbarian_mix.items.toml index 21bf93fda9..a4232a4f4b 100644 --- a/data/skill/herblore/barbarian_mix.items.toml +++ b/data/skill/herblore/barbarian_mix.items.toml @@ -471,7 +471,7 @@ id = 11501 price = 2155 weight = 0.075 heals = 30 -excess = "super_antipoison_mix_1" +excess = "antidote+_mix_1" empty = "vial" eat_message = "You drink the lumpy potion." examine = "Two doses of fishy extra strength antidote potion." diff --git a/data/skill/herblore/potion.items.toml b/data/skill/herblore/potion.items.toml index 4a8ee87132..2507b9b39a 100644 --- a/data/skill/herblore/potion.items.toml +++ b/data/skill/herblore/potion.items.toml @@ -24,6 +24,7 @@ id = 116 id = 117 price = 435 weight = 0.025 +excess = "strength_potion_1" empty = "vial" examine = "2 doses of strength potion." @@ -1561,31 +1562,31 @@ examine = "1 dose of extreme defence potion." [extreme_magic_4] id = 15320 -weight = 0.02 +weight = 0.035 excess = "extreme_magic_3" empty = "vial" -examine = "1 dose of extreme magic potion." +examine = "4 doses of extreme magic potion." [extreme_magic_3] id = 15321 -weight = 0.025 +weight = 0.03 excess = "extreme_magic_2" empty = "vial" -examine = "2 doses of extreme magic potion." +examine = "3 doses of extreme magic potion." [extreme_magic_2] id = 15322 -weight = 0.03 +weight = 0.025 excess = "extreme_magic_1" empty = "vial" -examine = "3 doses of extreme magic potion." +examine = "2 doses of extreme magic potion." [extreme_magic_1] id = 15323 -weight = 0.035 +weight = 0.02 excess = "vial" empty = "vial" -examine = "4 doses of extreme magic potion." +examine = "1 dose of extreme magic potion." [extreme_ranging_4] id = 15324 diff --git a/game/src/main/kotlin/content/skill/constitution/drink/DungeoneeringPotions.kt b/game/src/main/kotlin/content/skill/constitution/drink/DungeoneeringPotions.kt index 9d36e9ac4a..cc66449574 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/DungeoneeringPotions.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/DungeoneeringPotions.kt @@ -18,7 +18,7 @@ class DungeoneeringPotions : Script { levels.boost(Skill.Magic, 2, 0.07) } - consumed("weak_range_potion") { _, _ -> + consumed("weak_ranged_potion") { _, _ -> levels.boost(Skill.Ranged, 2, 0.07) } diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt b/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt index 916e678df9..76a5afc67c 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt @@ -31,7 +31,7 @@ class Tea : Script { } consumed("nettle_tea") { _, _ -> - runEnergy = (MAX_RUN_ENERGY / 100) * 5 + runEnergy += (MAX_RUN_ENERGY / 100) * 5 } itemOption("Look-in", "tea_flask") { diff --git a/game/src/test/kotlin/content/skill/constitution/drink/TeaTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/TeaTest.kt index 3861007ded..b986d34892 100644 --- a/game/src/test/kotlin/content/skill/constitution/drink/TeaTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/drink/TeaTest.kt @@ -67,6 +67,19 @@ internal class TeaTest : WorldTest() { assertEquals(500, player.runEnergy) } + @Test + fun `Nettle tea adds to existing energy`() { + val player = createPlayer(emptyTile) + player.inventory.add("nettle_tea") + player.experience.set(Skill.Constitution, Level.experience(15)) + player.levels.set(Skill.Constitution, 100) + player.runEnergy = 5000 + + player.itemOption("Drink", "nettle_tea") + + assertEquals(5500, player.runEnergy) + } + @Test fun `Tea flask boosts attack`() { val player = createPlayer(emptyTile) From c491f22e5fe444e3111f5bfb82f61b0327f68d87 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 11:52:20 -0700 Subject: [PATCH 02/15] Match potion effects on the exact dose base name `potionEffects` matched on `startsWith`, which made every effect depend on the order of the branches. `antipoison` was tested before `antipoison+` and `antipoison++`, so antidote+ and antidote++ granted 90 seconds of immunity instead of 9 and 12 minutes. The same flaw silently dropped potions whose name did not begin with the prefix of their family, such as `super_prayer` and `antidote+_mix`. Match on the item id with its dose suffix removed instead, so each family is selected by exact name and branch order carries no meaning. The dose handler treated any consumable ending in `_1` to `_4` as a potion, so cooked crab meat and stealing creation food reported doses remaining. Detect potions by their `empty` vial instead, which also covers the five dose stealing creation potions that the old range excluded. Energy potions assigned a tenth of the player's *current* run energy rather than adding a tenth of the maximum, so drinking one drained the player. Also removes the dead `smash_vials` block. It read a key that is never set, duplicating the vial smashing that `Eating.consume` already handles. --- .../skill/constitution/drink/PotionEffects.kt | 92 +++++++------- .../skill/constitution/drink/Potions.kt | 24 ++-- .../constitution/drink/PotionEffectsTest.kt | 118 ++++++++++++++++++ 3 files changed, 178 insertions(+), 56 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt diff --git a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt index 4b71a63eeb..6f7fbb5b21 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt @@ -7,6 +7,7 @@ import content.entity.effect.toxin.cureDisease import content.entity.player.combat.special.MAX_SPECIAL_ATTACK import content.entity.player.combat.special.specialAttackEnergy import content.entity.player.effect.antifire +import content.entity.player.effect.energy.MAX_RUN_ENERGY import content.entity.player.effect.energy.runEnergy import content.entity.player.effect.superAntifire import world.gregs.voidps.engine.client.message @@ -20,45 +21,52 @@ import java.util.concurrent.TimeUnit private fun Player.hasHolyItem() = equipped(EquipSlot.Cape).id.startsWith("prayer_cape") || carriesItem("holy_wrench") +private fun Player.restoreAllSkills() { + for (skill in Skill.all) { + if (skill == Skill.Constitution) { + continue + } + levels.restore(skill, 8, if (skill == Skill.Prayer && hasHolyItem()) 0.27 else 0.25) + } +} + +/** + * Applies the effect of drinking one dose of [potion], which may be any dose of the item id. + */ fun Player.potionEffects(potion: String) { - when { - potion.startsWith("antifire") -> antifire(6) - potion.startsWith("super_antifire") -> superAntifire(6) - potion.startsWith("overload") -> { + when (potion.substringBeforeLast('_')) { + "antifire", "antifire_mix" -> antifire(6) + "super_antifire" -> superAntifire(6) + "overload" -> { set("overload_refreshes_remaining", 20) timers.start("overload") } - potion.startsWith("attack_") -> levels.boost(Skill.Attack, 3, 0.1) - potion.startsWith("strength_") -> levels.boost(Skill.Strength, 3, 0.1) - potion.startsWith("defence_") -> levels.boost(Skill.Defence, 3, 0.1) - potion.startsWith("magic_essence") -> levels.boost(Skill.Magic, 3) - potion.startsWith("agility_") -> levels.boost(Skill.Agility, 3) - potion.startsWith("fishing_") -> levels.boost(Skill.Fishing, 3) - potion.startsWith("crafting_") -> levels.boost(Skill.Crafting, 3) - potion.startsWith("hunter_potion") || potion.startsWith("hunting_mix") -> levels.boost(Skill.Hunter, 3) - potion.startsWith("fletching_") -> levels.boost(Skill.Fletching, 3) - potion.startsWith("super_attack") -> levels.boost(Skill.Attack, 5, 0.15) - potion.startsWith("super_strength") -> levels.boost(Skill.Strength, 5, 0.15) - potion.startsWith("super_defence") -> levels.boost(Skill.Defence, 5, 0.15) - potion.startsWith("super_magic_") -> levels.boost(Skill.Magic, 5, 0.15) - potion.startsWith("super_ranging_") -> levels.boost(Skill.Ranged, 4, 0.10) - potion.startsWith("combat_") -> { + "attack_potion", "attack_mix" -> levels.boost(Skill.Attack, 3, 0.1) + "strength_potion", "strength_mix" -> levels.boost(Skill.Strength, 3, 0.1) + "defence_potion", "defence_mix" -> levels.boost(Skill.Defence, 3, 0.1) + "magic_essence", "magic_essence_mix" -> levels.boost(Skill.Magic, 3) + "agility_potion", "agility_mix" -> levels.boost(Skill.Agility, 3) + "fishing_potion", "fishing_mix" -> levels.boost(Skill.Fishing, 3) + "crafting_potion" -> levels.boost(Skill.Crafting, 3) + "hunter_potion", "hunting_mix" -> levels.boost(Skill.Hunter, 3) + "fletching_potion" -> levels.boost(Skill.Fletching, 3) + "super_attack", "super_attack_mix" -> levels.boost(Skill.Attack, 5, 0.15) + "super_strength", "super_strength_mix" -> levels.boost(Skill.Strength, 5, 0.15) + "super_defence", "super_defence_mix" -> levels.boost(Skill.Defence, 5, 0.15) + "super_magic_potion", "super_magic_mix" -> levels.boost(Skill.Magic, 5, 0.15) + "super_ranging_potion", "super_ranging_mix" -> levels.boost(Skill.Ranged, 4, 0.10) + "combat_potion", "combat_mix" -> { levels.boost(Skill.Attack, 3, 0.1) levels.boost(Skill.Strength, 3, 0.1) } - potion.startsWith("summoning_") -> { - levels.boost(Skill.Summoning, 7, 0.25) - // familiar.specialEnergy = (familiar.specialEnergy / 100) * 15 - } - potion.startsWith("relicyms_") -> cureDisease() - potion.startsWith("sanfew_serum") -> { + "summoning_potion" -> levels.boost(Skill.Summoning, 7, 0.25) + "relicyms_balm", "relicyms_mix" -> cureDisease() + "sanfew_serum" -> { antiPoison(6) antiDisease(15) - Skill.all.filterNot { it == Skill.Constitution }.forEach { skill -> - levels.restore(skill, 8, if (skill == Skill.Prayer && hasHolyItem()) 0.27 else 0.25) - } + restoreAllSkills() } - potion.startsWith("zamorak_") -> { + "zamorak_brew", "zamorak_mix" -> { levels.boost(Skill.Attack, 2, 0.2) levels.boost(Skill.Strength, 2, 0.12) levels.drain(Skill.Defence, 2, 0.1) @@ -66,7 +74,7 @@ fun Player.potionEffects(potion: String) { val damage = ((health / 100) * 10) + 20 directHit(damage) } - potion.startsWith("saradomin_brew") -> { + "saradomin_brew" -> { levels.boost(Skill.Constitution, 20, 0.15) levels.boost(Skill.Defence, 2, 0.2) levels.drain(Skill.Attack, 2, 0.1) @@ -74,26 +82,22 @@ fun Player.potionEffects(potion: String) { levels.drain(Skill.Magic, 2, 0.1) levels.drain(Skill.Ranged, 2, 0.1) } - potion.startsWith("prayer_") -> levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.27 else 0.25) - potion.startsWith("antipoison") -> antiPoison(90, TimeUnit.SECONDS) - potion.startsWith("super_antipoison") -> antiPoison(6) - potion.startsWith("antipoison++") -> antiPoison(12) - potion.startsWith("antipoison+") -> antiPoison(9) - potion.startsWith("restore_") -> { + "prayer_potion", "prayer_mix" -> levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.27 else 0.25) + "antipoison", "antipoison_mix" -> antiPoison(90, TimeUnit.SECONDS) + "super_antipoison", "super_antipoison_mix" -> antiPoison(6) + "antipoison+", "antidote+_mix" -> antiPoison(9) + "antipoison++" -> antiPoison(12) + "restore_potion", "restore_mix" -> { levels.restore(Skill.Attack, 10, 0.3) levels.restore(Skill.Strength, 10, 0.3) levels.restore(Skill.Defence, 10, 0.3) levels.restore(Skill.Magic, 10, 0.3) levels.restore(Skill.Ranged, 10, 0.3) } - potion.startsWith("super_restore") -> { - Skill.all.filterNot { it == Skill.Constitution }.forEach { skill -> - levels.restore(skill, 8, if (skill == Skill.Prayer && hasHolyItem()) 0.27 else 0.25) - } - } - potion.startsWith("energy_") -> runEnergy = (runEnergy / 100) * 10 - potion.startsWith("super_energy_") -> runEnergy = (runEnergy / 100) * 20 - potion.startsWith("recover_special") -> { + "super_restore", "super_restore_mix" -> restoreAllSkills() + "energy_potion", "energy_mix" -> runEnergy += (MAX_RUN_ENERGY / 100) * 10 + "super_energy", "super_energy_mix" -> runEnergy += (MAX_RUN_ENERGY / 100) * 20 + "recover_special" -> { specialAttackEnergy = (specialAttackEnergy + (MAX_SPECIAL_ATTACK / 4)).coerceAtMost(MAX_SPECIAL_ATTACK) val percentage = ((specialAttackEnergy / MAX_SPECIAL_ATTACK.toDouble()) * 100).toInt() message("Your special attack energy is now $percentage%.") diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt b/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt index 28327517b7..a8afc95d42 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt @@ -3,26 +3,26 @@ package content.skill.constitution.drink import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.plural -import world.gregs.voidps.engine.inv.inventory -import world.gregs.voidps.engine.inv.remove class Potions : Script { init { - consumed("*") { item, slot -> - if (!item.id.endsWith("_1") && !item.id.endsWith("_2") && !item.id.endsWith("_3") && !item.id.endsWith("_4")) { + consumed("*") { item, _ -> + if (!item.def["empty", ""].contains("vial")) { return@consumed } - val doses = item.id.last().digitToInt() - if (doses != 1) { - message("You have ${doses - 1} ${"dose".plural(doses - 1)} of the potion left.") - potionEffects(item.id) + val separator = item.id.lastIndexOf('_') + if (separator == -1) { + return@consumed + } + val doses = item.id.substring(separator + 1).toIntOrNull() + if (doses == null || doses !in 1..5) { return@consumed } - message("You have finished your potion.") - if (contains("smash_vials")) { - inventory.remove(slot, item.id) - message("You quickly smash the empty vial using the tick a Barbarian taught you.") + if (doses > 1) { + message("You have ${doses - 1} ${"dose".plural(doses - 1)} of the potion left.") + } else { + message("You have finished your potion.") } potionEffects(item.id) } diff --git a/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt new file mode 100644 index 0000000000..07349d2aac --- /dev/null +++ b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt @@ -0,0 +1,118 @@ +package content.skill.constitution.drink + +import WorldTest +import content.entity.player.effect.energy.runEnergy +import itemOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.engine.timer.toTicks +import java.util.concurrent.TimeUnit + +internal class PotionEffectsTest : WorldTest() { + + @Test + fun `Energy potion adds a tenth of maximum run energy`() { + val player = createPlayer(emptyTile) + player.inventory.add("energy_potion_4") + player.runEnergy = 5000 + + player.itemOption("Drink", "energy_potion_4") + + assertTrue(player.inventory.contains("energy_potion_3")) + assertEquals(6000, player.runEnergy) + } + + @Test + fun `Super energy potion adds a fifth of maximum run energy`() { + val player = createPlayer(emptyTile) + player.inventory.add("super_energy_4") + player.runEnergy = 5000 + + player.itemOption("Drink", "super_energy_4") + + assertTrue(player.inventory.contains("super_energy_3")) + assertEquals(7000, player.runEnergy) + } + + @Test + fun `Two dose strength potion can be drunk`() { + val player = createPlayer(emptyTile) + player.inventory.add("strength_potion_2") + + player.itemOption("Drink", "strength_potion_2") + + assertTrue(player.inventory.contains("strength_potion_1")) + assertEquals(4, player.levels.get(Skill.Strength)) + } + + @Test + fun `Weak ranged potion boosts ranged`() { + val player = createPlayer(emptyTile) + player.inventory.add("weak_ranged_potion") + + player.itemOption("Drink", "weak_ranged_potion") + + assertTrue(player.inventory.contains("vial_dungeoneering")) + assertEquals(3, player.levels.get(Skill.Ranged)) + } + + @Test + fun `Antidote plus mix keeps its own dose chain`() { + val player = createPlayer(emptyTile) + player.inventory.add("antidote+_mix_2") + player.experience.set(Skill.Constitution, Level.experience(15)) + player.levels.set(Skill.Constitution, 100) + + player.itemOption("Drink", "antidote+_mix_2") + + assertTrue(player.inventory.contains("antidote+_mix_1")) + } + + @Test + fun `Each antipoison tier gives its own immunity duration`() { + val tiers = mapOf( + "antipoison_4" to TimeUnit.SECONDS.toTicks(90), + "super_antipoison_4" to TimeUnit.MINUTES.toTicks(6), + "antipoison+_4" to TimeUnit.MINUTES.toTicks(9), + "antipoison++_4" to TimeUnit.MINUTES.toTicks(12), + ) + for ((potion, ticks) in tiers) { + val player = createPlayer(emptyTile) + player.inventory.add(potion) + + player.itemOption("Drink", potion) + + assertEquals(-(ticks * 2), player["poison", 0], potion) + } + } + + @Test + fun `Antidote plus mix gives nine minutes of immunity`() { + val player = createPlayer(emptyTile) + player.inventory.add("antidote+_mix_2") + player.experience.set(Skill.Constitution, Level.experience(15)) + player.levels.set(Skill.Constitution, 100) + + player.itemOption("Drink", "antidote+_mix_2") + + assertEquals(-(TimeUnit.MINUTES.toTicks(9) * 2), player["poison", 0]) + } + + @Test + fun `Food ending in a dose suffix is not treated as a potion`() { + val player = createPlayer(emptyTile) + player.inventory.add("cooked_crab_meat_4") + player.experience.set(Skill.Constitution, Level.experience(15)) + player.levels.set(Skill.Constitution, 100) + + player.itemOption("Eat", "cooked_crab_meat_4") + + assertTrue(player.inventory.contains("cooked_crab_meat_3")) + } + +} From 8f3b61eff917b8fa912f6312465f424b3c95a7d1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 11:52:47 -0700 Subject: [PATCH 03/15] Add extreme and super prayer potion effects Both families had item definitions and herblore recipes but no effect, so drinking one only consumed a dose. Overload grants the boost of all five extreme potions at once, so the two now share a single `extremeBoost` definition rather than repeating the amounts in both places where they could drift apart. Super prayer restores 7 prayer points plus 35% of the prayer level. The holy wrench and prayer cape bonus follows the two percentage point step the other prayer restoring potions already use. --- .../skill/constitution/drink/Overload.kt | 8 ++-- .../skill/constitution/drink/PotionEffects.kt | 17 +++++++ .../constitution/drink/PotionEffectsTest.kt | 46 +++++++++++++++++++ 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt b/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt index 1e896b4585..6a2cebff45 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt @@ -99,11 +99,9 @@ class Overload : Script { } fun applyBoost(player: Player) { - player.levels.boost(Skill.Attack, 5, 0.22) - player.levels.boost(Skill.Strength, 5, 0.22) - player.levels.boost(Skill.Defence, 5, 0.22) - player.levels.boost(Skill.Magic, 7) - player.levels.boost(Skill.Ranged, 4, 0.1923) + for (skill in skills) { + player.extremeBoost(skill) + } } fun removeBoost(player: Player) { diff --git a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt index 6f7fbb5b21..804fdc76c9 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt @@ -21,6 +21,17 @@ import java.util.concurrent.TimeUnit private fun Player.hasHolyItem() = equipped(EquipSlot.Cape).id.startsWith("prayer_cape") || carriesItem("holy_wrench") +/** + * Overload applies the boost of all five extreme potions at once, so both share this definition. + */ +fun Player.extremeBoost(skill: Skill) { + when (skill) { + Skill.Magic -> levels.boost(Skill.Magic, 7) + Skill.Ranged -> levels.boost(Skill.Ranged, 4, 0.1923) + else -> levels.boost(skill, 5, 0.22) + } +} + private fun Player.restoreAllSkills() { for (skill in Skill.all) { if (skill == Skill.Constitution) { @@ -41,6 +52,11 @@ fun Player.potionEffects(potion: String) { set("overload_refreshes_remaining", 20) timers.start("overload") } + "extreme_attack" -> extremeBoost(Skill.Attack) + "extreme_strength" -> extremeBoost(Skill.Strength) + "extreme_defence" -> extremeBoost(Skill.Defence) + "extreme_magic" -> extremeBoost(Skill.Magic) + "extreme_ranging" -> extremeBoost(Skill.Ranged) "attack_potion", "attack_mix" -> levels.boost(Skill.Attack, 3, 0.1) "strength_potion", "strength_mix" -> levels.boost(Skill.Strength, 3, 0.1) "defence_potion", "defence_mix" -> levels.boost(Skill.Defence, 3, 0.1) @@ -83,6 +99,7 @@ fun Player.potionEffects(potion: String) { levels.drain(Skill.Ranged, 2, 0.1) } "prayer_potion", "prayer_mix" -> levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.27 else 0.25) + "super_prayer" -> levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.37 else 0.35) "antipoison", "antipoison_mix" -> antiPoison(90, TimeUnit.SECONDS) "super_antipoison", "super_antipoison_mix" -> antiPoison(6) "antipoison+", "antidote+_mix" -> antiPoison(9) diff --git a/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt index 07349d2aac..d3b3beaebd 100644 --- a/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt @@ -115,4 +115,50 @@ internal class PotionEffectsTest : WorldTest() { assertTrue(player.inventory.contains("cooked_crab_meat_3")) } + @Test + fun `Extreme attack potion boosts attack past a super attack potion`() { + val player = createPlayer(emptyTile) + player.inventory.add("extreme_attack_4") + player.experience.set(Skill.Attack, Level.experience(99)) + player.levels.set(Skill.Attack, 99) + + player.itemOption("Drink", "extreme_attack_4") + + assertTrue(player.inventory.contains("extreme_attack_3")) + assertEquals(99 + 5 + 21, player.levels.get(Skill.Attack)) + } + + @Test + fun `Overload boosts ranged by the same amount as an extreme ranging potion`() { + val drinker = createPlayer(emptyTile) + drinker.inventory.add("extreme_ranging_4") + drinker.experience.set(Skill.Ranged, Level.experience(99)) + drinker.levels.set(Skill.Ranged, 99) + + drinker.itemOption("Drink", "extreme_ranging_4") + + val overloaded = createPlayer(emptyTile) + overloaded.inventory.add("overload_4") + overloaded.experience.set(Skill.Constitution, Level.experience(99)) + overloaded.levels.set(Skill.Constitution, 990) + overloaded.experience.set(Skill.Ranged, Level.experience(99)) + overloaded.levels.set(Skill.Ranged, 99) + + overloaded.itemOption("Drink", "overload_4") + + assertEquals(drinker.levels.get(Skill.Ranged), overloaded.levels.get(Skill.Ranged)) + } + + @Test + fun `Super prayer potion restores more than a prayer potion`() { + val player = createPlayer(emptyTile) + player.inventory.add("super_prayer_4") + player.experience.set(Skill.Prayer, Level.experience(99)) + player.levels.set(Skill.Prayer, 1) + + player.itemOption("Drink", "super_prayer_4") + + assertTrue(player.inventory.contains("super_prayer_3")) + assertEquals(1 + 7 + 34, player.levels.get(Skill.Prayer)) + } } From 8ca99cf83fb5dd1093a6fcbb6bdf488b042bc6fb Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 12:06:07 -0700 Subject: [PATCH 04/15] Make stealing creation and castle wars potions drinkable Both sets had item definitions but no dose chain and no empty vial, so they were rejected by the guard in `Eating.consume` and could not be drunk at all. Give each dose an `excess` and an `empty` vial, which also lets the dose handler recognise them. The stealing creation potions are five dose, so the last dose of each leaves a vial rather than a four dose potion. Each one reuses the effect of the potion it is a copy of rather than repeating the amounts, so the two cannot drift apart. The examine text identifies the tier: the stealing creation ranging, magic, defence, prayer and energy potions are the ordinary strength, while its attack and strength potions and all six castle wars potions are super potions. --- .../castle_wars/castle_wars.items.toml | 48 +++++++++++ .../stealing_creation.items.toml | 80 +++++++++++++++++++ .../skill/constitution/drink/PotionEffects.kt | 25 +++--- .../constitution/drink/PotionEffectsTest.kt | 39 +++++++++ 4 files changed, 182 insertions(+), 10 deletions(-) diff --git a/data/minigame/castle_wars/castle_wars.items.toml b/data/minigame/castle_wars/castle_wars.items.toml index 2bb3a3a282..e6eb65228e 100644 --- a/data/minigame/castle_wars/castle_wars.items.toml +++ b/data/minigame/castle_wars/castle_wars.items.toml @@ -358,121 +358,169 @@ kept = "Reclaim" [cw_super_attack_potion_4] id = 18715 weight = 0.035 +excess = "cw_super_attack_potion_3" +empty = "vial" examine = "4 doses of super attack potion." [cw_super_attack_potion_3] id = 18716 weight = 0.026 +excess = "cw_super_attack_potion_2" +empty = "vial" examine = "3 doses of super attack potion." [cw_super_attack_potion_2] id = 18717 weight = 0.017 +excess = "cw_super_attack_potion_1" +empty = "vial" examine = "2 doses of super attack potion." [cw_super_attack_potion_1] id = 18718 weight = 0.008 +excess = "vial" +empty = "vial" examine = "1 dose of super attack potion." [cw_super_strength_potion_4] id = 18719 weight = 0.035 +excess = "cw_super_strength_potion_3" +empty = "vial" examine = "4 doses of super strength potion." [cw_super_strength_potion_3] id = 18720 weight = 0.026 +excess = "cw_super_strength_potion_2" +empty = "vial" examine = "3 doses of super strength potion." [cw_super_strength_potion_2] id = 18721 weight = 0.017 +excess = "cw_super_strength_potion_1" +empty = "vial" examine = "2 doses of super strength potion." [cw_super_strength_potion_1] id = 18722 weight = 0.008 +excess = "vial" +empty = "vial" examine = "1 dose of super strength potion." [cw_super_defence_potion_4] id = 18723 weight = 0.035 +excess = "cw_super_defence_potion_3" +empty = "vial" examine = "4 doses of super defence potion." [cw_super_defence_potion_3] id = 18724 weight = 0.026 +excess = "cw_super_defence_potion_2" +empty = "vial" examine = "3 doses of super defence potion." [cw_super_defence_potion_2] id = 18725 weight = 0.017 +excess = "cw_super_defence_potion_1" +empty = "vial" examine = "2 doses of super defence potion." [cw_super_defence_potion_1] id = 18726 weight = 0.008 +excess = "vial" +empty = "vial" examine = "1 dose of super defence potion." [cw_super_energy_potion_4] id = 18727 weight = 0.035 +excess = "cw_super_energy_potion_3" +empty = "vial" examine = "4 doses of super energy potion." [cw_super_energy_potion_3] id = 18728 weight = 0.026 +excess = "cw_super_energy_potion_2" +empty = "vial" examine = "3 doses of super energy potion." [cw_super_energy_potion_2] id = 18729 weight = 0.017 +excess = "cw_super_energy_potion_1" +empty = "vial" examine = "2 doses of super energy potion." [cw_super_energy_potion_1] id = 18730 weight = 0.008 +excess = "vial" +empty = "vial" examine = "1 dose of super energy potion." [cw_super_ranging_potion_4] id = 18731 weight = 0.035 +excess = "cw_super_ranging_potion_3" +empty = "vial" examine = "4 doses of super ranging potion." [cw_super_ranging_potion_3] id = 18732 weight = 0.026 +excess = "cw_super_ranging_potion_2" +empty = "vial" examine = "3 doses of super ranging potion." [cw_super_ranging_potion_2] id = 18733 weight = 0.017 +excess = "cw_super_ranging_potion_1" +empty = "vial" examine = "2 doses of super ranging potion." [cw_super_ranging_potion_1] id = 18734 weight = 0.008 +excess = "vial" +empty = "vial" examine = "1 dose of super ranging potion." [cw_super_magic_potion_4] id = 18735 weight = 0.035 +excess = "cw_super_magic_potion_3" +empty = "vial" examine = "4 doses of super magic potion." [cw_super_magic_potion_3] id = 18736 weight = 0.026 +excess = "cw_super_magic_potion_2" +empty = "vial" examine = "3 doses of super magic potion." [cw_super_magic_potion_2] id = 18737 weight = 0.017 +excess = "cw_super_magic_potion_1" +empty = "vial" examine = "2 doses of super magic potion." [cw_super_magic_potion_1] id = 18738 weight = 0.008 +excess = "vial" +empty = "vial" examine = "1 dose of super magic potion." [castle_wars_flag_cape] diff --git a/data/minigame/stealing_creation/stealing_creation.items.toml b/data/minigame/stealing_creation/stealing_creation.items.toml index 49881d4e73..4b78d3e4f2 100644 --- a/data/minigame/stealing_creation/stealing_creation.items.toml +++ b/data/minigame/stealing_creation/stealing_creation.items.toml @@ -641,6 +641,8 @@ examine = "The sacred clay has transformed into a wood-like substance." [prayer_potion_stealing_creation_5] id = 14207 weight = 0.5 +excess = "prayer_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of Prayer restore potion." [prayer_potion_stealing_creation_5_noted] @@ -649,6 +651,8 @@ id = 14208 [prayer_potion_stealing_creation_4] id = 14209 weight = 0.4 +excess = "prayer_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of Prayer restore potion." [prayer_potion_stealing_creation_4_noted] @@ -657,6 +661,8 @@ id = 14210 [prayer_potion_stealing_creation_3] id = 14211 weight = 0.3 +excess = "prayer_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of Prayer restore potion." [prayer_potion_stealing_creation_3_noted] @@ -665,6 +671,8 @@ id = 14212 [prayer_potion_stealing_creation_2] id = 14213 weight = 0.2 +excess = "prayer_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of Prayer restore potion." [prayer_potion_stealing_creation_2_noted] @@ -673,6 +681,8 @@ id = 14214 [prayer_potion_stealing_creation_1] id = 14215 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of Prayer restore potion." [prayer_potion_stealing_creation_1_noted] @@ -681,6 +691,8 @@ id = 14216 [energy_potion_stealing_creation_5] id = 14217 weight = 0.5 +excess = "energy_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of energy potion." [energy_potion_stealing_creation_5_noted] @@ -689,6 +701,8 @@ id = 14218 [energy_potion_stealing_creation_4] id = 14219 weight = 0.4 +excess = "energy_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of energy potion." [energy_potion_stealing_creation_4_noted] @@ -697,6 +711,8 @@ id = 14220 [energy_potion_stealing_creation_3] id = 14221 weight = 0.3 +excess = "energy_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of energy potion." [energy_potion_stealing_creation_3_noted] @@ -705,6 +721,8 @@ id = 14222 [energy_potion_stealing_creation_2] id = 14223 weight = 0.2 +excess = "energy_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of energy potion." [energy_potion_stealing_creation_2_noted] @@ -713,6 +731,8 @@ id = 14224 [energy_potion_stealing_creation_1] id = 14225 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of energy potion." [energy_potion_stealing_creation_1_noted] @@ -721,6 +741,8 @@ id = 14226 [super_attack_potion_stealing_creation_5] id = 14227 weight = 0.5 +excess = "super_attack_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of super Attack potion." [super_attack_potion_stealing_creation_5_noted] @@ -729,6 +751,8 @@ id = 14228 [super_attack_potion_stealing_creation_4] id = 14229 weight = 0.4 +excess = "super_attack_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of super Attack potion." [super_attack_potion_stealing_creation_4_noted] @@ -737,6 +761,8 @@ id = 14230 [super_attack_potion_stealing_creation_3] id = 14231 weight = 0.3 +excess = "super_attack_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of super Attack potion." [super_attack_potion_stealing_creation_3_noted] @@ -745,6 +771,8 @@ id = 14232 [super_attack_potion_stealing_creation_2] id = 14233 weight = 0.2 +excess = "super_attack_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of super Attack potion." [super_attack_potion_stealing_creation_2_noted] @@ -753,6 +781,8 @@ id = 14234 [super_attack_potion_stealing_creation_1] id = 14235 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of super Attack potion." [super_attack_potion_stealing_creation_1_noted] @@ -761,6 +791,8 @@ id = 14236 [super_strength_potion_stealing_creation_5] id = 14237 weight = 0.5 +excess = "super_strength_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of super Strength potion." [super_strength_potion_stealing_creation_5_noted] @@ -769,6 +801,8 @@ id = 14238 [super_strength_potion_stealing_creation_4] id = 14239 weight = 0.4 +excess = "super_strength_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of super Strength potion." [super_strength_potion_stealing_creation_4_noted] @@ -777,6 +811,8 @@ id = 14240 [super_strength_potion_stealing_creation_3] id = 14241 weight = 0.3 +excess = "super_strength_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of super Strength potion." [super_strength_potion_stealing_creation_3_noted] @@ -785,6 +821,8 @@ id = 14242 [super_strength_potion_stealing_creation_2] id = 14243 weight = 0.2 +excess = "super_strength_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of super Strength potion." [super_strength_potion_stealing_creation_2_noted] @@ -793,6 +831,8 @@ id = 14244 [super_strength_potion_stealing_creation_1] id = 14245 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of super Strength potion." [super_strength_potion_stealing_creation_1_noted] @@ -801,6 +841,8 @@ id = 14246 [ranging_potion_stealing_creation_5] id = 14247 weight = 0.5 +excess = "ranging_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of ranging potion." [ranging_potion_stealing_creation_5_noted] @@ -809,6 +851,8 @@ id = 14248 [ranging_potion_stealing_creation_4] id = 14249 weight = 0.4 +excess = "ranging_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of ranging potion." [ranging_potion_stealing_creation_4_noted] @@ -817,6 +861,8 @@ id = 14250 [ranging_potion_stealing_creation_3] id = 14251 weight = 0.3 +excess = "ranging_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of ranging potion." [ranging_potion_stealing_creation_3_noted] @@ -825,6 +871,8 @@ id = 14252 [ranging_potion_stealing_creation_2] id = 14253 weight = 0.2 +excess = "ranging_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of ranging potion." [ranging_potion_stealing_creation_2_noted] @@ -833,6 +881,8 @@ id = 14254 [ranging_potion_stealing_creation_1] id = 14255 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of ranging potion." [ranging_potion_stealing_creation_1_noted] @@ -841,6 +891,8 @@ id = 14256 [defence_potion_stealing_creation_5] id = 14257 weight = 0.5 +excess = "defence_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of Defence potion." [defence_potion_stealing_creation_5_noted] @@ -849,6 +901,8 @@ id = 14258 [defence_potion_stealing_creation_4] id = 14259 weight = 0.4 +excess = "defence_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of Defence potion." [defence_potion_stealing_creation_4_noted] @@ -857,6 +911,8 @@ id = 14260 [defence_potion_stealing_creation_3] id = 14261 weight = 0.3 +excess = "defence_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of Defence potion." [defence_potion_stealing_creation_3_noted] @@ -865,6 +921,8 @@ id = 14262 [defence_potion_stealing_creation_2] id = 14263 weight = 0.2 +excess = "defence_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of Defence potion." [defence_potion_stealing_creation_2_noted] @@ -873,6 +931,8 @@ id = 14264 [defence_potion_stealing_creation_1] id = 14265 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of Defence potion." [defence_potion_stealing_creation_1_noted] @@ -881,6 +941,8 @@ id = 14266 [magic_potion_stealing_creation_5] id = 14267 weight = 0.5 +excess = "magic_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of Magic potion." [magic_potion_stealing_creation_5_noted] @@ -889,6 +951,8 @@ id = 14268 [magic_potion_stealing_creation_4] id = 14269 weight = 0.4 +excess = "magic_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of Magic potion." [magic_potion_stealing_creation_4_noted] @@ -897,6 +961,8 @@ id = 14270 [magic_potion_stealing_creation_3] id = 14271 weight = 0.3 +excess = "magic_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of Magic potion." [magic_potion_stealing_creation_3_noted] @@ -905,6 +971,8 @@ id = 14272 [magic_potion_stealing_creation_2] id = 14273 weight = 0.2 +excess = "magic_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of Magic potion." [magic_potion_stealing_creation_2_noted] @@ -913,6 +981,8 @@ id = 14274 [magic_potion_stealing_creation_1] id = 14275 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of Magic potion." [magic_potion_stealing_creation_1_noted] @@ -921,6 +991,8 @@ id = 14276 [summoning_potion_stealing_creation_5] id = 14277 weight = 0.5 +excess = "summoning_potion_stealing_creation_4" +empty = "vial" examine = "Five doses of Summoning potion." [summoning_potion_stealing_creation_5_noted] @@ -929,6 +1001,8 @@ id = 14278 [summoning_potion_stealing_creation_4] id = 14279 weight = 0.4 +excess = "summoning_potion_stealing_creation_3" +empty = "vial" examine = "Four doses of Summoning potion." [summoning_potion_stealing_creation_4_noted] @@ -937,6 +1011,8 @@ id = 14280 [summoning_potion_stealing_creation_3] id = 14281 weight = 0.3 +excess = "summoning_potion_stealing_creation_2" +empty = "vial" examine = "Three doses of Summoning potion." [summoning_potion_stealing_creation_3_noted] @@ -945,6 +1021,8 @@ id = 14282 [summoning_potion_stealing_creation_2] id = 14283 weight = 0.2 +excess = "summoning_potion_stealing_creation_1" +empty = "vial" examine = "Two doses of Summoning potion." [summoning_potion_stealing_creation_2_noted] @@ -953,6 +1031,8 @@ id = 14284 [summoning_potion_stealing_creation_1] id = 14285 weight = 0.1 +excess = "vial" +empty = "vial" examine = "One dose of Summoning potion." [summoning_potion_stealing_creation_1_noted] diff --git a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt index 804fdc76c9..9ee3bc904d 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt @@ -59,23 +59,27 @@ fun Player.potionEffects(potion: String) { "extreme_ranging" -> extremeBoost(Skill.Ranged) "attack_potion", "attack_mix" -> levels.boost(Skill.Attack, 3, 0.1) "strength_potion", "strength_mix" -> levels.boost(Skill.Strength, 3, 0.1) - "defence_potion", "defence_mix" -> levels.boost(Skill.Defence, 3, 0.1) + "defence_potion", "defence_mix", "defence_potion_stealing_creation" -> levels.boost(Skill.Defence, 3, 0.1) "magic_essence", "magic_essence_mix" -> levels.boost(Skill.Magic, 3) "agility_potion", "agility_mix" -> levels.boost(Skill.Agility, 3) "fishing_potion", "fishing_mix" -> levels.boost(Skill.Fishing, 3) "crafting_potion" -> levels.boost(Skill.Crafting, 3) "hunter_potion", "hunting_mix" -> levels.boost(Skill.Hunter, 3) "fletching_potion" -> levels.boost(Skill.Fletching, 3) - "super_attack", "super_attack_mix" -> levels.boost(Skill.Attack, 5, 0.15) - "super_strength", "super_strength_mix" -> levels.boost(Skill.Strength, 5, 0.15) - "super_defence", "super_defence_mix" -> levels.boost(Skill.Defence, 5, 0.15) - "super_magic_potion", "super_magic_mix" -> levels.boost(Skill.Magic, 5, 0.15) - "super_ranging_potion", "super_ranging_mix" -> levels.boost(Skill.Ranged, 4, 0.10) + "super_attack", "super_attack_mix", "super_attack_potion_stealing_creation", "cw_super_attack_potion" -> + levels.boost(Skill.Attack, 5, 0.15) + "super_strength", "super_strength_mix", "super_strength_potion_stealing_creation", "cw_super_strength_potion" -> + levels.boost(Skill.Strength, 5, 0.15) + "super_defence", "super_defence_mix", "cw_super_defence_potion" -> levels.boost(Skill.Defence, 5, 0.15) + "super_magic_potion", "super_magic_mix", "magic_potion_stealing_creation", "cw_super_magic_potion" -> + levels.boost(Skill.Magic, 5, 0.15) + "super_ranging_potion", "super_ranging_mix", "ranging_potion_stealing_creation", "cw_super_ranging_potion" -> + levels.boost(Skill.Ranged, 4, 0.10) "combat_potion", "combat_mix" -> { levels.boost(Skill.Attack, 3, 0.1) levels.boost(Skill.Strength, 3, 0.1) } - "summoning_potion" -> levels.boost(Skill.Summoning, 7, 0.25) + "summoning_potion", "summoning_potion_stealing_creation" -> levels.boost(Skill.Summoning, 7, 0.25) "relicyms_balm", "relicyms_mix" -> cureDisease() "sanfew_serum" -> { antiPoison(6) @@ -98,7 +102,8 @@ fun Player.potionEffects(potion: String) { levels.drain(Skill.Magic, 2, 0.1) levels.drain(Skill.Ranged, 2, 0.1) } - "prayer_potion", "prayer_mix" -> levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.27 else 0.25) + "prayer_potion", "prayer_mix", "prayer_potion_stealing_creation" -> + levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.27 else 0.25) "super_prayer" -> levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.37 else 0.35) "antipoison", "antipoison_mix" -> antiPoison(90, TimeUnit.SECONDS) "super_antipoison", "super_antipoison_mix" -> antiPoison(6) @@ -112,8 +117,8 @@ fun Player.potionEffects(potion: String) { levels.restore(Skill.Ranged, 10, 0.3) } "super_restore", "super_restore_mix" -> restoreAllSkills() - "energy_potion", "energy_mix" -> runEnergy += (MAX_RUN_ENERGY / 100) * 10 - "super_energy", "super_energy_mix" -> runEnergy += (MAX_RUN_ENERGY / 100) * 20 + "energy_potion", "energy_mix", "energy_potion_stealing_creation" -> runEnergy += (MAX_RUN_ENERGY / 100) * 10 + "super_energy", "super_energy_mix", "cw_super_energy_potion" -> runEnergy += (MAX_RUN_ENERGY / 100) * 20 "recover_special" -> { specialAttackEnergy = (specialAttackEnergy + (MAX_SPECIAL_ATTACK / 4)).coerceAtMost(MAX_SPECIAL_ATTACK) val percentage = ((specialAttackEnergy / MAX_SPECIAL_ATTACK.toDouble()) * 100).toInt() diff --git a/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt index d3b3beaebd..4ab211885b 100644 --- a/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt @@ -161,4 +161,43 @@ internal class PotionEffectsTest : WorldTest() { assertTrue(player.inventory.contains("super_prayer_3")) assertEquals(1 + 7 + 34, player.levels.get(Skill.Prayer)) } + + @Test + fun `Five dose stealing creation potion can be drunk`() { + val player = createPlayer(emptyTile) + player.inventory.add("super_attack_potion_stealing_creation_5") + player.experience.set(Skill.Attack, Level.experience(99)) + player.levels.set(Skill.Attack, 99) + + player.itemOption("Drink", "super_attack_potion_stealing_creation_5") + + assertTrue(player.inventory.contains("super_attack_potion_stealing_creation_4")) + assertEquals(99 + 5 + 14, player.levels.get(Skill.Attack)) + } + + @Test + fun `Last dose of a stealing creation potion leaves a vial`() { + val player = createPlayer(emptyTile) + player.inventory.add("prayer_potion_stealing_creation_1") + player.experience.set(Skill.Prayer, Level.experience(99)) + player.levels.set(Skill.Prayer, 1) + + player.itemOption("Drink", "prayer_potion_stealing_creation_1") + + assertTrue(player.inventory.contains("vial")) + assertEquals(1 + 7 + 24, player.levels.get(Skill.Prayer)) + } + + @Test + fun `Castle wars potion boosts the same as its super potion`() { + val player = createPlayer(emptyTile) + player.inventory.add("cw_super_strength_potion_4") + player.experience.set(Skill.Strength, Level.experience(99)) + player.levels.set(Skill.Strength, 99) + + player.itemOption("Drink", "cw_super_strength_potion_4") + + assertTrue(player.inventory.contains("cw_super_strength_potion_3")) + assertEquals(99 + 5 + 14, player.levels.get(Skill.Strength)) + } } From f6028429e478e186993cd093f37f2dfc8ec14bc0 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 12:16:47 -0700 Subject: [PATCH 05/15] Add poison chalice and cadava potion drink interactions The poison chalice was consumed and vanished without doing anything. It now leaves a cocktail glass and applies one of seven random outcomes, ranging from a slight taste of apricot through stat drains and heals to a large boost, or a small boost followed by damage. The two healing outcomes scale with the drinker's maximum life points. The cadava potion had neither `heals` nor `excess`, so drinking it was silently ignored. It is not meant to be drinkable, so it now answers with the refusal it should, and is still not consumed. Guthix balance and serums 207 and 208 are deliberately left alone. Both are used on something else rather than drunk, and neither has a drink effect to implement. --- .../ernest_the_chicken.items.toml | 2 + .../skill/constitution/drink/CadavaPotion.kt | 13 +++ .../skill/constitution/drink/PoisonChalice.kt | 67 +++++++++++++++ .../constitution/drink/PoisonChaliceTest.kt | 86 +++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 game/src/main/kotlin/content/skill/constitution/drink/CadavaPotion.kt create mode 100644 game/src/main/kotlin/content/skill/constitution/drink/PoisonChalice.kt create mode 100644 game/src/test/kotlin/content/skill/constitution/drink/PoisonChaliceTest.kt diff --git a/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml b/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml index 114500c421..935dd42eb3 100644 --- a/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml +++ b/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml @@ -4,6 +4,8 @@ price = 139 limit = 100 heals = 0 weight = 0.056 +excess = "cocktail_glass" +eat_message = "You drink the strange green liquid." examine = "Stankers gives out these strange cocktails for free." [poison_chalice_noted] diff --git a/game/src/main/kotlin/content/skill/constitution/drink/CadavaPotion.kt b/game/src/main/kotlin/content/skill/constitution/drink/CadavaPotion.kt new file mode 100644 index 0000000000..deef218984 --- /dev/null +++ b/game/src/main/kotlin/content/skill/constitution/drink/CadavaPotion.kt @@ -0,0 +1,13 @@ +package content.skill.constitution.drink + +import content.entity.player.dialogue.type.item +import world.gregs.voidps.engine.Script + +class CadavaPotion : Script { + + init { + itemOption("Drink", "cadava_potion") { + item("cadava_potion", "You dare not drink.") + } + } +} diff --git a/game/src/main/kotlin/content/skill/constitution/drink/PoisonChalice.kt b/game/src/main/kotlin/content/skill/constitution/drink/PoisonChalice.kt new file mode 100644 index 0000000000..a401308768 --- /dev/null +++ b/game/src/main/kotlin/content/skill/constitution/drink/PoisonChalice.kt @@ -0,0 +1,67 @@ +package content.skill.constitution.drink + +import content.entity.combat.hit.directHit +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.character.player.chat.ChatType +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.type.random + +class PoisonChalice : Script { + + init { + consumed("poison_chalice") { _, _ -> + when (random.nextInt(7)) { + 0 -> message("It has a slight taste of apricot.", ChatType.Filter) + 1 -> { + val amount = random.nextInt(1, 5) + levels.drain(Skill.Attack, amount) + levels.drain(Skill.Strength, amount) + levels.drain(Skill.Defence, amount) + levels.boost(Skill.Crafting, 1) + message("You feel a little better.", ChatType.Filter) + } + 2 -> { + levels.drain(Skill.Attack, 1) + levels.drain(Skill.Strength, 1) + levels.drain(Skill.Defence, 1) + levels.boost(Skill.Thieving, 1) + message("You feel a little strange.", ChatType.Filter) + } + 3 -> { + levels.restore(Skill.Constitution, heal(this, 10, 0.07)) + message("It heals some health.", ChatType.Filter) + } + 4 -> { + levels.restore(Skill.Constitution, heal(this, 20, 0.14)) + levels.boost(Skill.Thieving, 1) + message("You feel a lot better!", ChatType.Filter) + } + 5 -> { + levels.boost(Skill.Attack, 4) + levels.boost(Skill.Strength, 4) + levels.boost(Skill.Defence, 4) + levels.boost(Skill.Thieving, 1) + message("Wow! That was amazing! You feel really invigorated.", ChatType.Filter) + } + else -> { + val amount = random.nextInt(1, 3) + levels.boost(Skill.Attack, amount) + levels.boost(Skill.Strength, amount) + levels.boost(Skill.Defence, amount) + message("That tasted a bit dodgy. You feel a bit ill.", ChatType.Filter) + directHit(random.nextInt(1, 50)) + } + } + } + } + + /** + * Scales with the drinker's maximum life points, which are ten times their level. + */ + private fun heal(player: Player, base: Int, factor: Double): Int { + val maximum = player.levels.getMax(Skill.Constitution) + return base + ((maximum - 100) * factor).toInt() + } +} diff --git a/game/src/test/kotlin/content/skill/constitution/drink/PoisonChaliceTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/PoisonChaliceTest.kt new file mode 100644 index 0000000000..7b62183a86 --- /dev/null +++ b/game/src/test/kotlin/content/skill/constitution/drink/PoisonChaliceTest.kt @@ -0,0 +1,86 @@ +package content.skill.constitution.drink + +import FakeRandom +import WorldTest +import itemOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.setRandom + +internal class PoisonChaliceTest : WorldTest() { + + @Test + fun `Poison chalice can invigorate the drinker`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 7) 5 else from + }, + ) + val player = createPlayer(emptyTile) + player.inventory.add("poison_chalice") + + player.itemOption("Drink", "poison_chalice") + + assertTrue(player.inventory.contains("cocktail_glass")) + assertEquals(5, player.levels.get(Skill.Attack)) + assertEquals(5, player.levels.get(Skill.Strength)) + assertEquals(5, player.levels.get(Skill.Defence)) + assertEquals(2, player.levels.get(Skill.Thieving)) + } + + @Test + fun `Poison chalice can make the drinker ill`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = when (until) { + 7 -> 6 + 50 -> 30 + else -> from + } + }, + ) + val player = createPlayer(emptyTile) + player.inventory.add("poison_chalice") + player.experience.set(Skill.Constitution, Level.experience(10)) + + player.itemOption("Drink", "poison_chalice") + + assertTrue(player.inventory.contains("cocktail_glass")) + assertEquals(2, player.levels.get(Skill.Attack)) + assertEquals(70, player.levels.get(Skill.Constitution)) + } + + @Test + fun `Poison chalice can drain the drinker`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 7) 2 else from + }, + ) + val player = createPlayer(emptyTile) + player.inventory.add("poison_chalice") + player.experience.set(Skill.Attack, Level.experience(10)) + player.levels.set(Skill.Attack, 10) + + player.itemOption("Drink", "poison_chalice") + + assertEquals(9, player.levels.get(Skill.Attack)) + assertEquals(2, player.levels.get(Skill.Thieving)) + } + + @Test + fun `Cadava potion cannot be drunk`() { + val player = createPlayer(emptyTile) + player.inventory.add("cadava_potion") + + player.itemOption("Drink", "cadava_potion") + + assertTrue(player.interfaces.contains("dialogue_obj_box")) + assertTrue(player.inventory.contains("cadava_potion")) + } +} From 624f479f85c2ba7ccb20d520175934955aa43866 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 12:34:19 -0700 Subject: [PATCH 06/15] Add Herblore Habitat potion effects All ten juju potions had item definitions but no effect, so drinking one only consumed a dose. Each now starts a five minute effect. The mining and woodcutting potions do not bank every resource. Each ore or log gathered has a chance to open a short window during which they go to the bank instead of the inventory, which is what the graphics are for. The farming potion gives a chance of an extra herb, and only on herb patches. The fishing potion turns some raw sharks into raw baron sharks. The hunter potion is not drinkable and now says so rather than wasting a dose. The cooking potion has no effect yet. Scentless potion and the three god blessings start their effect but have nothing to act on, since the jadinkos they apply to do not exist yet. --- .../herblore_habitat.gfx.toml | 5 + .../content/skill/constitution/drink/Juju.kt | 89 +++++++++++++++++ .../skill/constitution/drink/PotionEffects.kt | 8 ++ .../content/skill/farming/FarmingPatchPick.kt | 6 ++ .../kotlin/content/skill/fishing/Fishing.kt | 5 + .../kotlin/content/skill/mining/Mining.kt | 4 +- .../content/skill/woodcutting/Woodcutting.kt | 5 + .../skill/constitution/drink/JujuTest.kt | 98 +++++++++++++++++++ 8 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 data/minigame/herblore_habitat/herblore_habitat.gfx.toml create mode 100644 game/src/main/kotlin/content/skill/constitution/drink/Juju.kt create mode 100644 game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt diff --git a/data/minigame/herblore_habitat/herblore_habitat.gfx.toml b/data/minigame/herblore_habitat/herblore_habitat.gfx.toml new file mode 100644 index 0000000000..0e957e7874 --- /dev/null +++ b/data/minigame/herblore_habitat/herblore_habitat.gfx.toml @@ -0,0 +1,5 @@ +[juju_mining_bank] +id = 2896 + +[juju_woodcutting_bank] +id = 2897 diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt b/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt new file mode 100644 index 0000000000..3f31ea9ac0 --- /dev/null +++ b/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt @@ -0,0 +1,89 @@ +package content.skill.constitution.drink + +import content.entity.player.bank.bank +import world.gregs.voidps.engine.Script +import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.timer.Timer +import world.gregs.voidps.type.random + +/** + * Herblore Habitat potions all last five minutes. The mining and woodcutting potions do not send + * every resource to the bank; each one gathered has a chance to open a short window during which + * they do. + */ +private const val INTERVAL = 25 +private const val DURATION = 500 / INTERVAL +private const val BANK_DURATION = 75 / INTERVAL +private const val BANK_CHANCE = 11 + +const val JUJU_HERB_CHANCE = 3 +const val JUJU_SHARK_CHANCE = 30 + +private val EFFECTS = listOf( + "juju_mining", + "juju_woodcutting", + "juju_farming", + "juju_fishing", + "scentless", + "saradomins_blessing", + "guthixs_gift", + "zamoraks_favour", +) + +private val WINDOWS = listOf("juju_mining_bank", "juju_woodcutting_bank") + +fun Player.startJuju(effect: String) { + set(effect, DURATION) + timers.restart(effect) +} + +fun Player.jujuActive(effect: String): Boolean = timers.contains(effect) + +/** + * Rolls for the banking window on every resource gathered, then banks [amount] of [item] for as + * long as that window stays open. + */ +fun Player.jujuBank(effect: String, item: String, amount: Int): Boolean { + if (jujuActive(effect) && random.nextInt(100) < BANK_CHANCE) { + set("${effect}_bank", BANK_DURATION) + timers.restart("${effect}_bank") + } + if (!jujuActive("${effect}_bank")) { + return false + } + bank.add(item, amount) + gfx("${effect}_bank") + return true +} + +class Juju : Script { + + init { + playerSpawn { + for (timer in EFFECTS + WINDOWS) { + if (get(timer, 0) > 0) { + timers.restart(timer) + } + } + } + + for (timer in EFFECTS + WINDOWS) { + timerStart(timer) { INTERVAL } + timerTick(timer) { + if (dec(timer) <= 0) Timer.CANCEL else Timer.CONTINUE + } + timerStop(timer) { logout -> + if (!logout) { + clear(timer) + } + } + } + + consumable("juju_hunter_potion*") { + message("You decide that only plants are likely to enjoy the taste of this potion.") + false + } + } +} diff --git a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt index 9ee3bc904d..8409a80cd6 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt @@ -80,6 +80,14 @@ fun Player.potionEffects(potion: String) { levels.boost(Skill.Strength, 3, 0.1) } "summoning_potion", "summoning_potion_stealing_creation" -> levels.boost(Skill.Summoning, 7, 0.25) + "juju_mining_potion" -> startJuju("juju_mining") + "juju_woodcutting_potion" -> startJuju("juju_woodcutting") + "juju_farming_potion" -> startJuju("juju_farming") + "juju_fishing_potion" -> startJuju("juju_fishing") + "scentless_potion" -> startJuju("scentless") + "saradomins_blessing" -> startJuju("saradomins_blessing") + "guthixs_gift" -> startJuju("guthixs_gift") + "zamoraks_favour" -> startJuju("zamoraks_favour") "relicyms_balm", "relicyms_mix" -> cureDisease() "sanfew_serum" -> { antiPoison(6) diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt index 7f8624f0d1..61fc373377 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt @@ -1,5 +1,7 @@ package content.skill.farming +import content.skill.constitution.drink.JUJU_HERB_CHANCE +import content.skill.constitution.drink.jujuActive import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.an @@ -20,6 +22,7 @@ import world.gregs.voidps.engine.inv.add import world.gregs.voidps.engine.inv.carriesItem import world.gregs.voidps.engine.inv.inventory import world.gregs.voidps.engine.queue.weakQueue +import world.gregs.voidps.type.random class FarmingPatchPick : Script { @@ -92,6 +95,9 @@ class FarmingPatchPick : Script { message("You have run out of inventory space.", ChatType.Filter) return@weakQueue } + if (jujuActive("juju_farming") && obj.id.startsWith("farming_herb_patch") && random.nextInt(JUJU_HERB_CHANCE) == 0) { + inventory.add(item.id) + } if (tree) { message("You pick ${item.id.an()} ${item.def.name.lowercase()}.", ChatType.Filter) } diff --git a/game/src/main/kotlin/content/skill/fishing/Fishing.kt b/game/src/main/kotlin/content/skill/fishing/Fishing.kt index 1618d423e3..09a1c24d00 100644 --- a/game/src/main/kotlin/content/skill/fishing/Fishing.kt +++ b/game/src/main/kotlin/content/skill/fishing/Fishing.kt @@ -1,6 +1,8 @@ package content.skill.fishing import com.github.michaelbull.logging.InlineLogger +import content.skill.constitution.drink.JUJU_SHARK_CHANCE +import content.skill.constitution.drink.jujuActive import content.skill.summoning.familiarActsAsHarpoon import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase @@ -147,6 +149,9 @@ class Fishing : Script { if (bigCatch(fish)) { fish = fish.replace("raw_", "big_") message = "You catch an enormous ${catch.toLowerSpaceCase()}!" + } else if (fish == "raw_shark" && player.jujuActive("juju_fishing") && random.nextInt(100) < JUJU_SHARK_CHANCE) { + fish = "raw_baron_shark" + message = "You catch a raw baron shark!" } player.inventory.add(fish) when (player.inventory.transaction.error) { diff --git a/game/src/main/kotlin/content/skill/mining/Mining.kt b/game/src/main/kotlin/content/skill/mining/Mining.kt index a208b86b67..009d2a14fa 100644 --- a/game/src/main/kotlin/content/skill/mining/Mining.kt +++ b/game/src/main/kotlin/content/skill/mining/Mining.kt @@ -4,6 +4,7 @@ import content.activity.shooting_star.ShootingStarHandler import content.entity.player.bank.bank import content.entity.player.bank.ownsItem import content.quest.questCompleted +import content.skill.constitution.drink.jujuBank import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase import world.gregs.voidps.engine.Script @@ -177,7 +178,8 @@ class Mining : Script { "mineral_deposit_coal" -> random.nextInt(1..2) else -> 1 } - val added = player.inventory.addToLimit(ore, amount) + val banked = player.jujuBank("juju_mining", ore, amount) + val added = if (banked) amount else player.inventory.addToLimit(ore, amount) when (added) { 1 -> player.message("You manage to mine some ${ore.toLowerSpaceCase()}.") 2 -> player.message("You manage to mine two ${ore.toLowerSpaceCase().plural(added)}!") diff --git a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt index aabaf76224..073ba126f9 100644 --- a/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt +++ b/game/src/main/kotlin/content/skill/woodcutting/Woodcutting.kt @@ -1,5 +1,6 @@ package content.skill.woodcutting +import content.skill.constitution.drink.jujuBank import content.skill.summoning.familiarBoost import net.pearx.kasechange.toLowerSpaceCase import world.gregs.voidps.engine.Script @@ -144,6 +145,10 @@ class Woodcutting(val drops: DropTables) : Script { if (log == "poison_ivy_berries") { return true } + if (player.jujuBank("juju_woodcutting", log, 1)) { + player.message("You get some ${log.toLowerSpaceCase()}.") + return true + } val added = player.inventory.add(log) if (added) { player.message("You get some ${log.toLowerSpaceCase()}.") diff --git a/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt new file mode 100644 index 0000000000..69359db35a --- /dev/null +++ b/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt @@ -0,0 +1,98 @@ +package content.skill.constitution.drink + +import FakeRandom +import WorldTest +import content.entity.player.bank.bank +import itemOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory +import world.gregs.voidps.type.setRandom + +internal class JujuTest : WorldTest() { + + @Test + fun `Juju mining potion starts a five minute effect`() { + val player = createPlayer(emptyTile) + player.inventory.add("juju_mining_potion_4") + + player.itemOption("Drink", "juju_mining_potion_4") + + assertTrue(player.inventory.contains("juju_mining_potion_3")) + assertTrue(player.jujuActive("juju_mining")) + } + + @Test + fun `Last dose of a juju potion leaves a juju vial`() { + val player = createPlayer(emptyTile) + player.inventory.add("juju_woodcutting_potion_1") + + player.itemOption("Drink", "juju_woodcutting_potion_1") + + assertTrue(player.inventory.contains("juju_vial")) + assertTrue(player.jujuActive("juju_woodcutting")) + } + + @Test + fun `Juju hunter potion is not drinkable`() { + val player = createPlayer(emptyTile) + player.inventory.add("juju_hunter_potion_4") + + player.itemOption("Drink", "juju_hunter_potion_4") + + assertTrue(player.inventory.contains("juju_hunter_potion_4")) + assertFalse(player.jujuActive("juju_hunter")) + } + + @Test + fun `God blessings start their own effects`() { + for (blessing in listOf("saradomins_blessing", "guthixs_gift", "zamoraks_favour")) { + val player = createPlayer(emptyTile) + player.inventory.add("${blessing}_4") + + player.itemOption("Drink", "${blessing}_4") + + assertTrue(player.inventory.contains("${blessing}_3"), blessing) + assertTrue(player.jujuActive(blessing), blessing) + } + } + + @Test + fun `Juju mining potion sends ore to the bank`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 100) 0 else from + }, + ) + val player = createPlayer(emptyTile) + player.experience.set(Skill.Mining, Level.experience(99)) + player.levels.set(Skill.Mining, 99) + player.startJuju("juju_mining") + + val banked = player.jujuBank("juju_mining", "copper_ore", 1) + + assertTrue(banked) + assertEquals(1, player.bank.count("copper_ore")) + assertFalse(player.inventory.contains("copper_ore")) + } + + @Test + fun `Ore is not banked without the potion`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 100) 0 else from + }, + ) + val player = createPlayer(emptyTile) + + val banked = player.jujuBank("juju_mining", "copper_ore", 1) + + assertFalse(banked) + assertEquals(0, player.bank.count("copper_ore")) + } +} From 2ca420a51d2aed72bb7a690bd9631de7ca6e2663 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 14:11:16 -0700 Subject: [PATCH 07/15] Fix juju potion effects being lost and destroying resources The juju variables were never declared, so they were written to the non-persisted map and dropped on save. That made the login handler that restores them dead code, since the counter it reads was always zero. Declare them alongside the other potion effect variables. Persisting them exposed a second problem. Death stops every timer as though the player had logged out, which skipped the clean up and left a counter behind with no timer running, so the effect would have come back on the next login. Clear the effects on death instead. The banking window ignored whether the bank accepted the item. A player mining or cutting with a full bank lost every resource for the duration of the potion, with a success message and full experience, and without even the floor drop that a full inventory falls back to. Return false when the bank refuses them so the caller keeps its normal handling. Also names the two rates for the units they are in. One was a one in three chance and the other a percentage, which was easy to misread. --- data/skill/herblore/herblore.vars.toml | 40 ++++++++++ .../content/skill/constitution/drink/Juju.kt | 29 +++++--- .../content/skill/farming/FarmingPatchPick.kt | 4 +- .../kotlin/content/skill/fishing/Fishing.kt | 4 +- .../skill/constitution/drink/JujuTest.kt | 74 +++++++++++++++++++ 5 files changed, 137 insertions(+), 14 deletions(-) diff --git a/data/skill/herblore/herblore.vars.toml b/data/skill/herblore/herblore.vars.toml index 8895c8191b..0fa2261d42 100644 --- a/data/skill/herblore/herblore.vars.toml +++ b/data/skill/herblore/herblore.vars.toml @@ -2,6 +2,46 @@ format = "int" persist = true +[juju_mining] +format = "int" +persist = true + +[juju_woodcutting] +format = "int" +persist = true + +[juju_farming] +format = "int" +persist = true + +[juju_fishing] +format = "int" +persist = true + +[scentless] +format = "int" +persist = true + +[saradomins_blessing] +format = "int" +persist = true + +[guthixs_gift] +format = "int" +persist = true + +[zamoraks_favour] +format = "int" +persist = true + +[juju_mining_bank] +format = "int" +persist = true + +[juju_woodcutting_bank] +format = "int" +persist = true + [overload_refreshes_remaining] format = "int" persist = true diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt b/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt index 3f31ea9ac0..fb5c840953 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt @@ -9,17 +9,17 @@ import world.gregs.voidps.engine.timer.Timer import world.gregs.voidps.type.random /** - * Herblore Habitat potions all last five minutes. The mining and woodcutting potions do not send - * every resource to the bank; each one gathered has a chance to open a short window during which - * they do. + * Herblore Habitat potions all last five minutes. The mining and woodcutting potions open a + * banking window rather than sending every resource to the bank, though the roll is repeated on + * each resource gathered, so in practice the window is usually refreshed before it closes. */ private const val INTERVAL = 25 private const val DURATION = 500 / INTERVAL private const val BANK_DURATION = 75 / INTERVAL -private const val BANK_CHANCE = 11 +private const val BANK_PERCENT = 11 -const val JUJU_HERB_CHANCE = 3 -const val JUJU_SHARK_CHANCE = 30 +const val JUJU_HERB_ONE_IN = 3 +const val JUJU_SHARK_PERCENT = 30 private val EFFECTS = listOf( "juju_mining", @@ -42,18 +42,21 @@ fun Player.startJuju(effect: String) { fun Player.jujuActive(effect: String): Boolean = timers.contains(effect) /** - * Rolls for the banking window on every resource gathered, then banks [amount] of [item] for as - * long as that window stays open. + * Rolls for the banking window on every resource gathered, then banks [amount] of [item] while + * that window is open. Returns false when the bank cannot take them, so the caller keeps its + * normal inventory handling rather than destroying the resource. */ fun Player.jujuBank(effect: String, item: String, amount: Int): Boolean { - if (jujuActive(effect) && random.nextInt(100) < BANK_CHANCE) { + if (jujuActive(effect) && random.nextInt(100) < BANK_PERCENT) { set("${effect}_bank", BANK_DURATION) timers.restart("${effect}_bank") } if (!jujuActive("${effect}_bank")) { return false } - bank.add(item, amount) + if (!bank.add(item, amount)) { + return false + } gfx("${effect}_bank") return true } @@ -69,6 +72,12 @@ class Juju : Script { } } + playerDeath { + for (timer in EFFECTS + WINDOWS) { + clear(timer) + } + } + for (timer in EFFECTS + WINDOWS) { timerStart(timer) { INTERVAL } timerTick(timer) { diff --git a/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt b/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt index 61fc373377..23cc5bb2c2 100644 --- a/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt +++ b/game/src/main/kotlin/content/skill/farming/FarmingPatchPick.kt @@ -1,6 +1,6 @@ package content.skill.farming -import content.skill.constitution.drink.JUJU_HERB_CHANCE +import content.skill.constitution.drink.JUJU_HERB_ONE_IN import content.skill.constitution.drink.jujuActive import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message @@ -95,7 +95,7 @@ class FarmingPatchPick : Script { message("You have run out of inventory space.", ChatType.Filter) return@weakQueue } - if (jujuActive("juju_farming") && obj.id.startsWith("farming_herb_patch") && random.nextInt(JUJU_HERB_CHANCE) == 0) { + if (jujuActive("juju_farming") && obj.id.startsWith("farming_herb_patch") && random.nextInt(JUJU_HERB_ONE_IN) == 0) { inventory.add(item.id) } if (tree) { diff --git a/game/src/main/kotlin/content/skill/fishing/Fishing.kt b/game/src/main/kotlin/content/skill/fishing/Fishing.kt index 09a1c24d00..247cbc8343 100644 --- a/game/src/main/kotlin/content/skill/fishing/Fishing.kt +++ b/game/src/main/kotlin/content/skill/fishing/Fishing.kt @@ -1,7 +1,7 @@ package content.skill.fishing import com.github.michaelbull.logging.InlineLogger -import content.skill.constitution.drink.JUJU_SHARK_CHANCE +import content.skill.constitution.drink.JUJU_SHARK_PERCENT import content.skill.constitution.drink.jujuActive import content.skill.summoning.familiarActsAsHarpoon import content.skill.summoning.familiarBoost @@ -149,7 +149,7 @@ class Fishing : Script { if (bigCatch(fish)) { fish = fish.replace("raw_", "big_") message = "You catch an enormous ${catch.toLowerSpaceCase()}!" - } else if (fish == "raw_shark" && player.jujuActive("juju_fishing") && random.nextInt(100) < JUJU_SHARK_CHANCE) { + } else if (fish == "raw_shark" && player.jujuActive("juju_fishing") && random.nextInt(100) < JUJU_SHARK_PERCENT) { fish = "raw_baron_shark" message = "You catch a raw baron shark!" } diff --git a/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt index 69359db35a..6121378161 100644 --- a/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt @@ -8,6 +8,10 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.config.VariableDefinition.Companion.persist +import world.gregs.voidps.engine.data.definition.InventoryDefinitions +import world.gregs.voidps.engine.data.definition.VariableDefinitions +import world.gregs.voidps.engine.entity.character.player.Player import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.character.player.skill.level.Level import world.gregs.voidps.engine.inv.add @@ -95,4 +99,74 @@ internal class JujuTest : WorldTest() { assertFalse(banked) assertEquals(0, player.bank.count("copper_ore")) } + + @Test + fun `Ore is kept when the bank is full`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 100) 0 else from + }, + ) + val player = createPlayer(emptyTile) + player.startJuju("juju_mining") + fillBank(player) + + val banked = player.jujuBank("juju_mining", "copper_ore", 1) + + assertFalse(banked) + assertFalse(player.bank.contains("copper_ore")) + } + + @Test + fun `Logs are kept when the bank is full`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 100) 0 else from + }, + ) + val player = createPlayer(emptyTile) + player.startJuju("juju_woodcutting") + fillBank(player) + + val banked = player.jujuBank("juju_woodcutting", "logs", 1) + + assertFalse(banked) + assertFalse(player.bank.contains("logs")) + } + + @Test + fun `Juju effects persist across logout`() { + for (key in listOf( + "juju_mining", + "juju_woodcutting", + "juju_farming", + "juju_fishing", + "scentless", + "saradomins_blessing", + "guthixs_gift", + "zamoraks_favour", + "juju_mining_bank", + "juju_woodcutting_bank", + )) { + assertTrue(VariableDefinitions.get(key).persist, key) + } + } + + @Test + fun `Dying clears juju effects`() { + val player = createPlayer(emptyTile) + player.startJuju("juju_mining") + assertTrue(player.jujuActive("juju_mining")) + + player.levels.set(Skill.Constitution, 0) + tick(12) + + assertEquals(0, player["juju_mining", 0]) + } + + private fun fillBank(player: Player) { + for (index in 0 until InventoryDefinitions.get("bank").length) { + player.bank.set(index, "coins", 1) + } + } } From 6c29662560c1c776f0d0c20bba97683057224190 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 14:52:34 -0700 Subject: [PATCH 08/15] Tighten potion detection and juju banking window The dose handler decided whether an item was a potion by asking whether its empty container contained "vial". That also matches the dungeoneering vial and the moonshine vial, and only avoided misreading them because no such item id happens to end in a digit. Match the two real vials exactly. The banking window was persisted and restored, so logging out with one open and a potion that had already expired gave a further seventy five ticks of free banking on the next login. It is a transient window, so stop saving it and stop restoring it. A full bank now closes the window and says so, rather than quietly falling back to the inventory on every swing with no explanation. The poison chalice was left without an empty container, unlike the six other drinks that leave a cocktail glass. --- .../ernest_the_chicken.items.toml | 1 + data/skill/herblore/herblore.vars.toml | 6 +++-- .../content/skill/constitution/drink/Juju.kt | 8 +++--- .../skill/constitution/drink/Potions.kt | 8 +++++- .../skill/constitution/drink/JujuTest.kt | 25 +++++++++++++++++-- 5 files changed, 40 insertions(+), 8 deletions(-) diff --git a/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml b/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml index 935dd42eb3..18f381b241 100644 --- a/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml +++ b/data/quest/free/ernest_the_chicken/ernest_the_chicken.items.toml @@ -5,6 +5,7 @@ limit = 100 heals = 0 weight = 0.056 excess = "cocktail_glass" +empty = "cocktail_glass" eat_message = "You drink the strange green liquid." examine = "Stankers gives out these strange cocktails for free." diff --git a/data/skill/herblore/herblore.vars.toml b/data/skill/herblore/herblore.vars.toml index 0fa2261d42..798bf07503 100644 --- a/data/skill/herblore/herblore.vars.toml +++ b/data/skill/herblore/herblore.vars.toml @@ -34,13 +34,15 @@ persist = true format = "int" persist = true +# Transient banking window, deliberately not carried across a logout. [juju_mining_bank] format = "int" -persist = true +persist = false +# Transient banking window, deliberately not carried across a logout. [juju_woodcutting_bank] format = "int" -persist = true +persist = false [overload_refreshes_remaining] format = "int" diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt b/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt index fb5c840953..44243e338b 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Juju.kt @@ -43,8 +43,8 @@ fun Player.jujuActive(effect: String): Boolean = timers.contains(effect) /** * Rolls for the banking window on every resource gathered, then banks [amount] of [item] while - * that window is open. Returns false when the bank cannot take them, so the caller keeps its - * normal inventory handling rather than destroying the resource. + * that window is open. Returns false when the bank cannot take them, closing the window so the + * caller keeps its normal inventory handling rather than destroying the resource. */ fun Player.jujuBank(effect: String, item: String, amount: Int): Boolean { if (jujuActive(effect) && random.nextInt(100) < BANK_PERCENT) { @@ -55,6 +55,8 @@ fun Player.jujuBank(effect: String, item: String, amount: Int): Boolean { return false } if (!bank.add(item, amount)) { + timers.stop("${effect}_bank") + message("Your bank is too full to send anything else to it.") return false } gfx("${effect}_bank") @@ -65,7 +67,7 @@ class Juju : Script { init { playerSpawn { - for (timer in EFFECTS + WINDOWS) { + for (timer in EFFECTS) { if (get(timer, 0) > 0) { timers.restart(timer) } diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt b/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt index a8afc95d42..4978c66a90 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt @@ -4,11 +4,17 @@ import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.plural +/** + * The vials a dosed potion is left in. Matched exactly rather than by substring, so an unrelated + * container such as a dungeoneering vial cannot start being read as a potion. + */ +private val VIALS = setOf("vial", "juju_vial") + class Potions : Script { init { consumed("*") { item, _ -> - if (!item.def["empty", ""].contains("vial")) { + if (item.def["empty", ""] !in VIALS) { return@consumed } val separator = item.id.lastIndexOf('_') diff --git a/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt index 6121378161..841b3e90ef 100644 --- a/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/drink/JujuTest.kt @@ -145,13 +145,34 @@ internal class JujuTest : WorldTest() { "saradomins_blessing", "guthixs_gift", "zamoraks_favour", - "juju_mining_bank", - "juju_woodcutting_bank", )) { assertTrue(VariableDefinitions.get(key).persist, key) } } + @Test + fun `Banking windows do not persist across logout`() { + for (key in listOf("juju_mining_bank", "juju_woodcutting_bank")) { + assertFalse(VariableDefinitions.get(key).persist, key) + } + } + + @Test + fun `A full bank closes the banking window`() { + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 100) 0 else from + }, + ) + val player = createPlayer(emptyTile) + player.startJuju("juju_mining") + fillBank(player) + + player.jujuBank("juju_mining", "copper_ore", 1) + + assertFalse(player.jujuActive("juju_mining_bank")) + } + @Test fun `Dying clears juju effects`() { val player = createPlayer(emptyTile) From a95f02586b8b8aa982ece6bfeda57f84dbcc4a4f Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 14:59:59 -0700 Subject: [PATCH 09/15] Fix antifire never wearing off and overload surviving death Antifire decremented its remaining time by zero, because the second argument of `dec` is the amount to subtract rather than a default. The counter was read unchanged on every tick and never reached zero, so one dose of antifire lasted until the player died or logged out instead of six minutes. Both dragonfire timers shared one stop handler that cleared both counters, so a super antifire running out also removed a regular antifire that still had time left. Give each timer its own handler. That handler also ignored whether it was stopping because the player logged out, so it wiped the effect on the way out and made persisting it pointless. Leave the counter alone on logout and clear it on death, which is what the persistence was for. Overload had the same death problem. Its counter persists and death stops timers as though the player had logged out, so the guard skipped the clean up and the effect came back on the next login. --- .../skill/constitution/drink/Antifire.kt | 18 ++++-- .../skill/constitution/drink/Overload.kt | 4 ++ .../skill/constitution/drink/AntifireTest.kt | 64 +++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 game/src/test/kotlin/content/skill/constitution/drink/AntifireTest.kt diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Antifire.kt b/game/src/main/kotlin/content/skill/constitution/drink/Antifire.kt index 169f88bad4..7bd4ead34e 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Antifire.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Antifire.kt @@ -19,15 +19,21 @@ class Antifire : Script { } } + playerDeath { + clear("antifire") + clear("super_antifire") + } + timerStart("fire_resistance") { 30 } timerStart("fire_immunity") { 20 } timerTick("fire_resistance") { decrease(this, "antifire") } timerTick("fire_immunity") { decrease(this, "super_antifire") } - timerStop("fire_resistance,fire_immunity", ::clear) + timerStop("fire_resistance") { logout -> expire(this, "antifire", logout) } + timerStop("fire_immunity") { logout -> expire(this, "super_antifire", logout) } } fun decrease(player: Player, key: String): Int { - val remaining = player.dec(key, 0) + val remaining = player.dec(key) if (remaining <= 0) { return Timer.CANCEL } @@ -37,9 +43,11 @@ class Antifire : Script { return Timer.CONTINUE } - fun clear(player: Player, logout: Boolean) { + fun expire(player: Player, key: String, logout: Boolean) { + if (logout) { + return + } player.message("Your resistance to dragonfire has run out.") - player["antifire"] = 0 - player["super_antifire"] = 0 + player.clear(key) } } diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt b/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt index 6a2cebff45..ad1ab3dd07 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt @@ -28,6 +28,10 @@ class Overload : Script { } } + playerDeath { + set("overload_refreshes_remaining", 0) + } + timerStart("overload", ::start) timerTick("overload", ::tick) timerStop("overload", ::stop) diff --git a/game/src/test/kotlin/content/skill/constitution/drink/AntifireTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/AntifireTest.kt new file mode 100644 index 0000000000..50a11ef461 --- /dev/null +++ b/game/src/test/kotlin/content/skill/constitution/drink/AntifireTest.kt @@ -0,0 +1,64 @@ +package content.skill.constitution.drink + +import WorldTest +import content.entity.player.effect.antifire +import content.entity.player.effect.superAntifire +import itemOption +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +internal class AntifireTest : WorldTest() { + + @Test + fun `Antifire potion wears off`() { + val player = createPlayer(emptyTile) + player.inventory.add("antifire_4") + + player.itemOption("Drink", "antifire_4") + assertTrue(player.antifire) + + tick(601) + + assertFalse(player.antifire) + } + + @Test + fun `Super antifire wearing off leaves antifire running`() { + val player = createPlayer(emptyTile) + player.antifire(6) + player.superAntifire(1) + + tick(150) + + assertFalse(player.superAntifire) + assertTrue(player.antifire) + } + + @Test + fun `Dying clears dragonfire resistance`() { + val player = createPlayer(emptyTile) + player.antifire(6) + player.superAntifire(6) + + player.levels.set(Skill.Constitution, 0) + tick(12) + + assertFalse(player.antifire) + assertFalse(player.superAntifire) + } + + @Test + fun `Dying clears an overload`() { + val player = createPlayer(emptyTile) + player["overload_refreshes_remaining"] = 20 + + player.levels.set(Skill.Constitution, 0) + tick(12) + + assertFalse(player.get("overload_refreshes_remaining", 0) > 0) + } +} From 63f3db19aa925c2dd93dc5e8ba4ad8b7216275ae Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 15:49:35 -0700 Subject: [PATCH 10/15] Stop a full mining yield reporting an empty inventory Gold mineral deposits give up to four ore at once, but the message only had cases for one, two and three, so a successful four ore haul fell through to the branch that tells the player they have no inventory space. The remaining case is still the one that matters, where nothing could be added at all. --- .../kotlin/content/skill/mining/Mining.kt | 1 + .../kotlin/content/skill/mining/MiningTest.kt | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/game/src/main/kotlin/content/skill/mining/Mining.kt b/game/src/main/kotlin/content/skill/mining/Mining.kt index 009d2a14fa..59c35b9ff0 100644 --- a/game/src/main/kotlin/content/skill/mining/Mining.kt +++ b/game/src/main/kotlin/content/skill/mining/Mining.kt @@ -184,6 +184,7 @@ class Mining : Script { 1 -> player.message("You manage to mine some ${ore.toLowerSpaceCase()}.") 2 -> player.message("You manage to mine two ${ore.toLowerSpaceCase().plural(added)}!") 3 -> player.message("You manage to mine three ${ore.toLowerSpaceCase().plural(added)}!") + 4 -> player.message("You manage to mine four ${ore.toLowerSpaceCase().plural(added)}!") else -> player.inventoryFull() } if (diaryDoubleOre(player, ore)) { diff --git a/game/src/test/kotlin/content/skill/mining/MiningTest.kt b/game/src/test/kotlin/content/skill/mining/MiningTest.kt index 40d48c89ac..d741296ea3 100644 --- a/game/src/test/kotlin/content/skill/mining/MiningTest.kt +++ b/game/src/test/kotlin/content/skill/mining/MiningTest.kt @@ -1,7 +1,10 @@ package content.skill.mining +import FakeRandom import WorldTest +import containsMessage import objectOption +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNotEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -31,4 +34,26 @@ internal class MiningTest : WorldTest() { assertTrue(player.experience.get(Skill.Mining) > 0) assertNotEquals(rocks.id, GameObjects.getLayer(tile, ObjectLayer.GROUND)?.id) } + + @Test + fun `Mining a full yield does not claim the inventory is full`() { + // Force the yield roll to its maximum, but leave the success and gem rolls to chance. + val rolls = Random(42) + setRandom( + object : FakeRandom() { + override fun nextInt(from: Int, until: Int): Int = if (until == 5) 4 else rolls.nextInt(from, until) + override fun nextInt(until: Int): Int = rolls.nextInt(until) + }, + ) + val player = createPlayer(emptyTile) + player.levels.set(Skill.Mining, 100) + val deposit = createObject("mineral_deposit_gold", emptyTile.addY(1)) + player.inventory.add("bronze_pickaxe") + + player.objectOption(deposit, "Mine") + tickIf { !player.inventory.contains("gold_ore") } + + assertTrue(player.containsMessage("You manage to mine four gold ore")) + assertFalse(player.containsMessage("You don't have enough inventory space.")) + } } From 0d395e316a113a6da01b8ed4844e41afd15edaa6 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sat, 5 Sep 2026 17:49:22 -0700 Subject: [PATCH 11/15] Fix spiced wine emptying into an item that does not exist Spiced wine listed its container as "jub", which matches nothing. Its own destroy text calls it a jug, and jug of wine empties into "jug". Adds a test that every excess and empty names a real item, since neither is looked up until a player drinks something, so a typo sits unnoticed. --- .../missing_my_mummy.items.toml | 2 +- .../skill/constitution/ConsumableItemsTest.kt | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt diff --git a/data/quest/members/missing_my_mummy/missing_my_mummy.items.toml b/data/quest/members/missing_my_mummy/missing_my_mummy.items.toml index 0247a5655f..913987d9d5 100644 --- a/data/quest/members/missing_my_mummy/missing_my_mummy.items.toml +++ b/data/quest/members/missing_my_mummy/missing_my_mummy.items.toml @@ -146,7 +146,7 @@ kept = "Wilderness" id = 14815 tradeable = false weight = 0.453 -empty = "jub" +empty = "jug" destroy = "I can make a new jug of spiced wine using wine and spices." examine = "Spiced in a way unpalatable to modern tastes." kept = "Wilderness" diff --git a/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt b/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt new file mode 100644 index 0000000000..db9be78bef --- /dev/null +++ b/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt @@ -0,0 +1,27 @@ +package content.skill.constitution + +import WorldTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.ItemDefinitions + +internal class ConsumableItemsTest : WorldTest() { + + /** + * `excess` is the item left after a dose is drunk and `empty` is the container it came in, so + * both have to name a real item or the consumable silently breaks. + */ + @Test + fun `Every excess and empty item exists`() { + val missing = mutableListOf() + for (definition in ItemDefinitions.definitions) { + for (param in listOf("excess", "empty")) { + val target: String = definition.getOrNull(param) ?: continue + if (!ItemDefinitions.ids.containsKey(target)) { + missing.add("${definition.stringId} $param = \"$target\"") + } + } + } + assertEquals(emptyList(), missing) + } +} From f41926cde608ba3a5f58708d5822cc64221559a7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 6 Sep 2026 14:30:26 -0700 Subject: [PATCH 12/15] Break stealing creation and castle wars potions on the last dose These leave nothing behind rather than an empty vial, so the last dose now removes the item instead of handing back a vial the player could carry out of the minigame. The empty vial still records the container the potion comes in, which is what marks it as a dosed potion, but it is no longer given out. --- .../castle_wars/castle_wars.items.toml | 12 +++++------ .../stealing_creation.items.toml | 16 +++++++-------- .../constitution/drink/PotionEffectsTest.kt | 20 +++++++++++++++++-- 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/data/minigame/castle_wars/castle_wars.items.toml b/data/minigame/castle_wars/castle_wars.items.toml index e6eb65228e..3aebceec91 100644 --- a/data/minigame/castle_wars/castle_wars.items.toml +++ b/data/minigame/castle_wars/castle_wars.items.toml @@ -379,7 +379,7 @@ examine = "2 doses of super attack potion." [cw_super_attack_potion_1] id = 18718 weight = 0.008 -excess = "vial" +heals = 0 empty = "vial" examine = "1 dose of super attack potion." @@ -407,7 +407,7 @@ examine = "2 doses of super strength potion." [cw_super_strength_potion_1] id = 18722 weight = 0.008 -excess = "vial" +heals = 0 empty = "vial" examine = "1 dose of super strength potion." @@ -435,7 +435,7 @@ examine = "2 doses of super defence potion." [cw_super_defence_potion_1] id = 18726 weight = 0.008 -excess = "vial" +heals = 0 empty = "vial" examine = "1 dose of super defence potion." @@ -463,7 +463,7 @@ examine = "2 doses of super energy potion." [cw_super_energy_potion_1] id = 18730 weight = 0.008 -excess = "vial" +heals = 0 empty = "vial" examine = "1 dose of super energy potion." @@ -491,7 +491,7 @@ examine = "2 doses of super ranging potion." [cw_super_ranging_potion_1] id = 18734 weight = 0.008 -excess = "vial" +heals = 0 empty = "vial" examine = "1 dose of super ranging potion." @@ -519,7 +519,7 @@ examine = "2 doses of super magic potion." [cw_super_magic_potion_1] id = 18738 weight = 0.008 -excess = "vial" +heals = 0 empty = "vial" examine = "1 dose of super magic potion." diff --git a/data/minigame/stealing_creation/stealing_creation.items.toml b/data/minigame/stealing_creation/stealing_creation.items.toml index 4b78d3e4f2..6e43012e43 100644 --- a/data/minigame/stealing_creation/stealing_creation.items.toml +++ b/data/minigame/stealing_creation/stealing_creation.items.toml @@ -681,7 +681,7 @@ id = 14214 [prayer_potion_stealing_creation_1] id = 14215 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of Prayer restore potion." @@ -731,7 +731,7 @@ id = 14224 [energy_potion_stealing_creation_1] id = 14225 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of energy potion." @@ -781,7 +781,7 @@ id = 14234 [super_attack_potion_stealing_creation_1] id = 14235 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of super Attack potion." @@ -831,7 +831,7 @@ id = 14244 [super_strength_potion_stealing_creation_1] id = 14245 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of super Strength potion." @@ -881,7 +881,7 @@ id = 14254 [ranging_potion_stealing_creation_1] id = 14255 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of ranging potion." @@ -931,7 +931,7 @@ id = 14264 [defence_potion_stealing_creation_1] id = 14265 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of Defence potion." @@ -981,7 +981,7 @@ id = 14274 [magic_potion_stealing_creation_1] id = 14275 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of Magic potion." @@ -1031,7 +1031,7 @@ id = 14284 [summoning_potion_stealing_creation_1] id = 14285 weight = 0.1 -excess = "vial" +heals = 0 empty = "vial" examine = "One dose of Summoning potion." diff --git a/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt index 4ab211885b..ee8da32fb2 100644 --- a/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/drink/PotionEffectsTest.kt @@ -4,6 +4,7 @@ import WorldTest import content.entity.player.effect.energy.runEnergy import itemOption import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import world.gregs.voidps.engine.entity.character.player.skill.Skill @@ -176,7 +177,7 @@ internal class PotionEffectsTest : WorldTest() { } @Test - fun `Last dose of a stealing creation potion leaves a vial`() { + fun `Last dose of a stealing creation potion breaks`() { val player = createPlayer(emptyTile) player.inventory.add("prayer_potion_stealing_creation_1") player.experience.set(Skill.Prayer, Level.experience(99)) @@ -184,10 +185,25 @@ internal class PotionEffectsTest : WorldTest() { player.itemOption("Drink", "prayer_potion_stealing_creation_1") - assertTrue(player.inventory.contains("vial")) + assertFalse(player.inventory.contains("prayer_potion_stealing_creation_1")) + assertFalse(player.inventory.contains("vial")) assertEquals(1 + 7 + 24, player.levels.get(Skill.Prayer)) } + @Test + fun `Last dose of a castle wars potion breaks`() { + val player = createPlayer(emptyTile) + player.inventory.add("cw_super_strength_potion_1") + player.experience.set(Skill.Strength, Level.experience(99)) + player.levels.set(Skill.Strength, 99) + + player.itemOption("Drink", "cw_super_strength_potion_1") + + assertFalse(player.inventory.contains("cw_super_strength_potion_1")) + assertFalse(player.inventory.contains("vial")) + assertEquals(99 + 5 + 14, player.levels.get(Skill.Strength)) + } + @Test fun `Castle wars potion boosts the same as its super potion`() { val player = createPlayer(emptyTile) From a082374261932ce69cdcd0d8fc532761fd49e8b7 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 6 Sep 2026 15:04:21 -0700 Subject: [PATCH 13/15] Recognise potions by their effect rather than their container The dose handler decided whether an item was a potion by looking at the container it empties into, which is a field nothing else in the tree reads, and which says nothing about whether the item is a potion. The stealing creation and castle wars potions made that plain: they are potions that leave no container at all. Turn the effect table into the registry of dosed potions and ask it instead. A potion whose effect is not implemented is registered with an empty one, so it still reports its doses, and the registry doubles as the list of what is missing. Guthix rest moves in with the rest of the dosed potions rather than sitting with the teas, and registering the same name twice now fails at startup instead of quietly winning. Adds a test that the registry and the item data agree in both directions, so a new potion cannot be added without an effect and an effect cannot name an item that does not exist. --- .../skill/constitution/drink/PotionEffects.kt | 133 ++++++++++-------- .../skill/constitution/drink/Potions.kt | 12 +- .../content/skill/constitution/drink/Tea.kt | 10 -- .../skill/constitution/ConsumableItemsTest.kt | 33 +++++ 4 files changed, 114 insertions(+), 74 deletions(-) diff --git a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt index 8409a80cd6..50d2995560 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/PotionEffects.kt @@ -4,6 +4,8 @@ import content.entity.combat.hit.directHit import content.entity.effect.toxin.antiDisease import content.entity.effect.toxin.antiPoison import content.entity.effect.toxin.cureDisease +import content.entity.effect.toxin.poisonDamage +import content.entity.effect.toxin.poisoned import content.entity.player.combat.special.MAX_SPECIAL_ATTACK import content.entity.player.combat.special.specialAttackEnergy import content.entity.player.effect.antifire @@ -41,60 +43,63 @@ private fun Player.restoreAllSkills() { } } +private fun MutableMap Unit>.effect(vararg names: String, effect: Player.() -> Unit) { + for (name in names) { + require(put(name, effect) == null) { "Duplicate potion effect '$name'." } + } +} + /** - * Applies the effect of drinking one dose of [potion], which may be any dose of the item id. + * Every dosed potion, keyed by its item id with the dose suffix removed. Membership is what marks + * an item as a potion, so a potion with no effect yet is still registered with an empty one. */ -fun Player.potionEffects(potion: String) { - when (potion.substringBeforeLast('_')) { - "antifire", "antifire_mix" -> antifire(6) - "super_antifire" -> superAntifire(6) - "overload" -> { +object PotionEffects { + val effects: Map Unit> = buildMap { + effect("antifire", "antifire_mix") { antifire(6) } + effect("super_antifire") { superAntifire(6) } + effect("overload") { set("overload_refreshes_remaining", 20) timers.start("overload") } - "extreme_attack" -> extremeBoost(Skill.Attack) - "extreme_strength" -> extremeBoost(Skill.Strength) - "extreme_defence" -> extremeBoost(Skill.Defence) - "extreme_magic" -> extremeBoost(Skill.Magic) - "extreme_ranging" -> extremeBoost(Skill.Ranged) - "attack_potion", "attack_mix" -> levels.boost(Skill.Attack, 3, 0.1) - "strength_potion", "strength_mix" -> levels.boost(Skill.Strength, 3, 0.1) - "defence_potion", "defence_mix", "defence_potion_stealing_creation" -> levels.boost(Skill.Defence, 3, 0.1) - "magic_essence", "magic_essence_mix" -> levels.boost(Skill.Magic, 3) - "agility_potion", "agility_mix" -> levels.boost(Skill.Agility, 3) - "fishing_potion", "fishing_mix" -> levels.boost(Skill.Fishing, 3) - "crafting_potion" -> levels.boost(Skill.Crafting, 3) - "hunter_potion", "hunting_mix" -> levels.boost(Skill.Hunter, 3) - "fletching_potion" -> levels.boost(Skill.Fletching, 3) - "super_attack", "super_attack_mix", "super_attack_potion_stealing_creation", "cw_super_attack_potion" -> - levels.boost(Skill.Attack, 5, 0.15) - "super_strength", "super_strength_mix", "super_strength_potion_stealing_creation", "cw_super_strength_potion" -> - levels.boost(Skill.Strength, 5, 0.15) - "super_defence", "super_defence_mix", "cw_super_defence_potion" -> levels.boost(Skill.Defence, 5, 0.15) - "super_magic_potion", "super_magic_mix", "magic_potion_stealing_creation", "cw_super_magic_potion" -> - levels.boost(Skill.Magic, 5, 0.15) - "super_ranging_potion", "super_ranging_mix", "ranging_potion_stealing_creation", "cw_super_ranging_potion" -> - levels.boost(Skill.Ranged, 4, 0.10) - "combat_potion", "combat_mix" -> { + effect("extreme_attack") { extremeBoost(Skill.Attack) } + effect("extreme_strength") { extremeBoost(Skill.Strength) } + effect("extreme_defence") { extremeBoost(Skill.Defence) } + effect("extreme_magic") { extremeBoost(Skill.Magic) } + effect("extreme_ranging") { extremeBoost(Skill.Ranged) } + effect("attack_potion", "attack_mix") { levels.boost(Skill.Attack, 3, 0.1) } + effect("strength_potion", "strength_mix") { levels.boost(Skill.Strength, 3, 0.1) } + effect("defence_potion", "defence_mix", "defence_potion_stealing_creation") { levels.boost(Skill.Defence, 3, 0.1) } + effect("magic_essence", "magic_essence_mix") { levels.boost(Skill.Magic, 3) } + effect("agility_potion", "agility_mix") { levels.boost(Skill.Agility, 3) } + effect("fishing_potion", "fishing_mix") { levels.boost(Skill.Fishing, 3) } + effect("crafting_potion") { levels.boost(Skill.Crafting, 3) } + effect("hunter_potion", "hunting_mix") { levels.boost(Skill.Hunter, 3) } + effect("fletching_potion") { levels.boost(Skill.Fletching, 3) } + effect("super_attack", "super_attack_mix", "super_attack_potion_stealing_creation", "cw_super_attack_potion") { levels.boost(Skill.Attack, 5, 0.15) } + effect("super_strength", "super_strength_mix", "super_strength_potion_stealing_creation", "cw_super_strength_potion") { levels.boost(Skill.Strength, 5, 0.15) } + effect("super_defence", "super_defence_mix", "cw_super_defence_potion") { levels.boost(Skill.Defence, 5, 0.15) } + effect("super_magic_potion", "super_magic_mix", "magic_potion_stealing_creation", "cw_super_magic_potion") { levels.boost(Skill.Magic, 5, 0.15) } + effect("super_ranging_potion", "super_ranging_mix", "ranging_potion_stealing_creation", "cw_super_ranging_potion") { levels.boost(Skill.Ranged, 4, 0.10) } + effect("combat_potion", "combat_mix") { levels.boost(Skill.Attack, 3, 0.1) levels.boost(Skill.Strength, 3, 0.1) } - "summoning_potion", "summoning_potion_stealing_creation" -> levels.boost(Skill.Summoning, 7, 0.25) - "juju_mining_potion" -> startJuju("juju_mining") - "juju_woodcutting_potion" -> startJuju("juju_woodcutting") - "juju_farming_potion" -> startJuju("juju_farming") - "juju_fishing_potion" -> startJuju("juju_fishing") - "scentless_potion" -> startJuju("scentless") - "saradomins_blessing" -> startJuju("saradomins_blessing") - "guthixs_gift" -> startJuju("guthixs_gift") - "zamoraks_favour" -> startJuju("zamoraks_favour") - "relicyms_balm", "relicyms_mix" -> cureDisease() - "sanfew_serum" -> { + effect("summoning_potion", "summoning_potion_stealing_creation") { levels.boost(Skill.Summoning, 7, 0.25) } + effect("juju_mining_potion") { startJuju("juju_mining") } + effect("juju_woodcutting_potion") { startJuju("juju_woodcutting") } + effect("juju_farming_potion") { startJuju("juju_farming") } + effect("juju_fishing_potion") { startJuju("juju_fishing") } + effect("scentless_potion") { startJuju("scentless") } + effect("saradomins_blessing") { startJuju("saradomins_blessing") } + effect("guthixs_gift") { startJuju("guthixs_gift") } + effect("zamoraks_favour") { startJuju("zamoraks_favour") } + effect("relicyms_balm", "relicyms_mix") { cureDisease() } + effect("sanfew_serum") { antiPoison(6) antiDisease(15) restoreAllSkills() } - "zamorak_brew", "zamorak_mix" -> { + effect("zamorak_brew", "zamorak_mix") { levels.boost(Skill.Attack, 2, 0.2) levels.boost(Skill.Strength, 2, 0.12) levels.drain(Skill.Defence, 2, 0.1) @@ -102,7 +107,7 @@ fun Player.potionEffects(potion: String) { val damage = ((health / 100) * 10) + 20 directHit(damage) } - "saradomin_brew" -> { + effect("saradomin_brew") { levels.boost(Skill.Constitution, 20, 0.15) levels.boost(Skill.Defence, 2, 0.2) levels.drain(Skill.Attack, 2, 0.1) @@ -110,29 +115,47 @@ fun Player.potionEffects(potion: String) { levels.drain(Skill.Magic, 2, 0.1) levels.drain(Skill.Ranged, 2, 0.1) } - "prayer_potion", "prayer_mix", "prayer_potion_stealing_creation" -> - levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.27 else 0.25) - "super_prayer" -> levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.37 else 0.35) - "antipoison", "antipoison_mix" -> antiPoison(90, TimeUnit.SECONDS) - "super_antipoison", "super_antipoison_mix" -> antiPoison(6) - "antipoison+", "antidote+_mix" -> antiPoison(9) - "antipoison++" -> antiPoison(12) - "restore_potion", "restore_mix" -> { + effect("prayer_potion", "prayer_mix", "prayer_potion_stealing_creation") { levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.27 else 0.25) } + effect("super_prayer") { levels.restore(Skill.Prayer, 7, if (hasHolyItem()) 0.37 else 0.35) } + effect("antipoison", "antipoison_mix") { antiPoison(90, TimeUnit.SECONDS) } + effect("super_antipoison", "super_antipoison_mix") { antiPoison(6) } + effect("antipoison+", "antidote+_mix") { antiPoison(9) } + effect("antipoison++") { antiPoison(12) } + effect("restore_potion", "restore_mix") { levels.restore(Skill.Attack, 10, 0.3) levels.restore(Skill.Strength, 10, 0.3) levels.restore(Skill.Defence, 10, 0.3) levels.restore(Skill.Magic, 10, 0.3) levels.restore(Skill.Ranged, 10, 0.3) } - "super_restore", "super_restore_mix" -> restoreAllSkills() - "energy_potion", "energy_mix", "energy_potion_stealing_creation" -> runEnergy += (MAX_RUN_ENERGY / 100) * 10 - "super_energy", "super_energy_mix", "cw_super_energy_potion" -> runEnergy += (MAX_RUN_ENERGY / 100) * 20 - "recover_special" -> { + effect("super_restore", "super_restore_mix") { restoreAllSkills() } + effect("energy_potion", "energy_mix", "energy_potion_stealing_creation") { runEnergy += (MAX_RUN_ENERGY / 100) * 10 } + effect("super_energy", "super_energy_mix", "cw_super_energy_potion") { runEnergy += (MAX_RUN_ENERGY / 100) * 20 } + effect("recover_special") { specialAttackEnergy = (specialAttackEnergy + (MAX_SPECIAL_ATTACK / 4)).coerceAtMost(MAX_SPECIAL_ATTACK) val percentage = ((specialAttackEnergy / MAX_SPECIAL_ATTACK.toDouble()) * 100).toInt() message("Your special attack energy is now $percentage%.") set("recover_special_delay", TimeUnit.SECONDS.toTicks(30) / 10) softTimers.start("recover_special") } + // Drinkable, but with no effect of their own. + effect("guthix_rest") { + if (poisoned) { + poisonDamage -= 10 + } + runEnergy += (MAX_RUN_ENERGY / 100) * 5 + levels.boost(Skill.Constitution, 50, maximum = 50) + } + // Registered so a dose still reports itself, but their effects are not implemented. + effect("juju_cooking_potion", "juju_hunter_potion") { } + // Used on something else rather than drunk, so drinking them does nothing. + effect("compost_potion", "guthix_balance", "serum_207", "serum_208", "olive_oil", "sacred_oil") { } } } + +/** + * Applies the effect of drinking one dose of [potion], which may be any dose of the item id. + */ +fun Player.potionEffects(potion: String) { + PotionEffects.effects[potion.substringBeforeLast('_')]?.invoke(this) +} diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt b/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt index 4978c66a90..10564926bc 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Potions.kt @@ -4,19 +4,10 @@ import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message import world.gregs.voidps.engine.client.ui.chat.plural -/** - * The vials a dosed potion is left in. Matched exactly rather than by substring, so an unrelated - * container such as a dungeoneering vial cannot start being read as a potion. - */ -private val VIALS = setOf("vial", "juju_vial") - class Potions : Script { init { consumed("*") { item, _ -> - if (item.def["empty", ""] !in VIALS) { - return@consumed - } val separator = item.id.lastIndexOf('_') if (separator == -1) { return@consumed @@ -25,6 +16,9 @@ class Potions : Script { if (doses == null || doses !in 1..5) { return@consumed } + if (!PotionEffects.effects.containsKey(item.id.substring(0, separator))) { + return@consumed + } if (doses > 1) { message("You have ${doses - 1} ${"dose".plural(doses - 1)} of the potion left.") } else { diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt b/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt index 76a5afc67c..cef2a86394 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Tea.kt @@ -1,7 +1,5 @@ package content.skill.constitution.drink -import content.entity.effect.toxin.poisonDamage -import content.entity.effect.toxin.poisoned import content.entity.player.dialogue.type.item import content.entity.player.effect.energy.MAX_RUN_ENERGY import content.entity.player.effect.energy.runEnergy @@ -22,14 +20,6 @@ class Tea : Script { levels.boost(Skill.Attack, 3) } - consumed("guthix_rest_4,guthix_rest_3,guthix_rest_2,guthix_rest_1") { _, _ -> - if (poisoned) { - poisonDamage -= 10 - } - runEnergy += (MAX_RUN_ENERGY / 100) * 5 - levels.boost(Skill.Constitution, 50, maximum = 50) - } - consumed("nettle_tea") { _, _ -> runEnergy += (MAX_RUN_ENERGY / 100) * 5 } diff --git a/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt b/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt index db9be78bef..f5858eb4ef 100644 --- a/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt @@ -1,6 +1,7 @@ package content.skill.constitution import WorldTest +import content.skill.constitution.drink.PotionEffects import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import world.gregs.voidps.engine.data.definition.ItemDefinitions @@ -24,4 +25,36 @@ internal class ConsumableItemsTest : WorldTest() { } assertEquals(emptyList(), missing) } + + /** + * A dosed potion is recognised by its base name, so one missing from the registry is drunk + * silently and one in the registry that no item matches is a typo. + */ + @Test + fun `Every dosed potion is registered and every registered potion exists`() { + val bases = mutableSetOf() + for (definition in ItemDefinitions.definitions) { + val id = definition.stringId + if (id.endsWith("_noted") || !id.dropLast(1).endsWith("_")) { + continue + } + if (id.last() !in '1'..'5') { + continue + } + if (!definition.contains("heals") && !definition.contains("excess")) { + continue + } + val empty: String? = definition.getOrNull("empty") + if (empty == null || !empty.contains("vial")) { + continue + } + bases.add(id.substringBeforeLast('_')) + } + + assertEquals(emptySet(), bases - PotionEffects.effects.keys, "dosed potions with no effect entry") + val unmatched = PotionEffects.effects.keys.filter { base -> + (1..5).none { ItemDefinitions.ids.containsKey("${base}_$it") } + } + assertEquals(emptyList(), unmatched, "effect entries matching no item") + } } From b086e6f7f5b95670eead1913abeaa81c7e619ddc Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 6 Sep 2026 15:20:39 -0700 Subject: [PATCH 14/15] Drop the empty vial from potions that do not leave one Stealing creation and castle wars potions break rather than leaving a vial, so they no longer name a container they never hand back. Nothing reads the field now that potions are recognised by their effect. Removing it took those two sets out of what the coverage test could see, since it found dosed potions by looking for a vial. It now walks every dosed consumable and subtracts the ones that only look like potions, which covers the potions without a container as well. That wider walk also reads items that inherit their doses from a clone, which is how the second copy of jug of bad wine turned up. --- .../castle_wars/castle_wars.items.toml | 24 ----------- .../stealing_creation.items.toml | 40 ------------------- .../skill/constitution/ConsumableItemsTest.kt | 27 ++++++++++--- 3 files changed, 21 insertions(+), 70 deletions(-) diff --git a/data/minigame/castle_wars/castle_wars.items.toml b/data/minigame/castle_wars/castle_wars.items.toml index 3aebceec91..03cee77a4f 100644 --- a/data/minigame/castle_wars/castle_wars.items.toml +++ b/data/minigame/castle_wars/castle_wars.items.toml @@ -359,168 +359,144 @@ kept = "Reclaim" id = 18715 weight = 0.035 excess = "cw_super_attack_potion_3" -empty = "vial" examine = "4 doses of super attack potion." [cw_super_attack_potion_3] id = 18716 weight = 0.026 excess = "cw_super_attack_potion_2" -empty = "vial" examine = "3 doses of super attack potion." [cw_super_attack_potion_2] id = 18717 weight = 0.017 excess = "cw_super_attack_potion_1" -empty = "vial" examine = "2 doses of super attack potion." [cw_super_attack_potion_1] id = 18718 weight = 0.008 heals = 0 -empty = "vial" examine = "1 dose of super attack potion." [cw_super_strength_potion_4] id = 18719 weight = 0.035 excess = "cw_super_strength_potion_3" -empty = "vial" examine = "4 doses of super strength potion." [cw_super_strength_potion_3] id = 18720 weight = 0.026 excess = "cw_super_strength_potion_2" -empty = "vial" examine = "3 doses of super strength potion." [cw_super_strength_potion_2] id = 18721 weight = 0.017 excess = "cw_super_strength_potion_1" -empty = "vial" examine = "2 doses of super strength potion." [cw_super_strength_potion_1] id = 18722 weight = 0.008 heals = 0 -empty = "vial" examine = "1 dose of super strength potion." [cw_super_defence_potion_4] id = 18723 weight = 0.035 excess = "cw_super_defence_potion_3" -empty = "vial" examine = "4 doses of super defence potion." [cw_super_defence_potion_3] id = 18724 weight = 0.026 excess = "cw_super_defence_potion_2" -empty = "vial" examine = "3 doses of super defence potion." [cw_super_defence_potion_2] id = 18725 weight = 0.017 excess = "cw_super_defence_potion_1" -empty = "vial" examine = "2 doses of super defence potion." [cw_super_defence_potion_1] id = 18726 weight = 0.008 heals = 0 -empty = "vial" examine = "1 dose of super defence potion." [cw_super_energy_potion_4] id = 18727 weight = 0.035 excess = "cw_super_energy_potion_3" -empty = "vial" examine = "4 doses of super energy potion." [cw_super_energy_potion_3] id = 18728 weight = 0.026 excess = "cw_super_energy_potion_2" -empty = "vial" examine = "3 doses of super energy potion." [cw_super_energy_potion_2] id = 18729 weight = 0.017 excess = "cw_super_energy_potion_1" -empty = "vial" examine = "2 doses of super energy potion." [cw_super_energy_potion_1] id = 18730 weight = 0.008 heals = 0 -empty = "vial" examine = "1 dose of super energy potion." [cw_super_ranging_potion_4] id = 18731 weight = 0.035 excess = "cw_super_ranging_potion_3" -empty = "vial" examine = "4 doses of super ranging potion." [cw_super_ranging_potion_3] id = 18732 weight = 0.026 excess = "cw_super_ranging_potion_2" -empty = "vial" examine = "3 doses of super ranging potion." [cw_super_ranging_potion_2] id = 18733 weight = 0.017 excess = "cw_super_ranging_potion_1" -empty = "vial" examine = "2 doses of super ranging potion." [cw_super_ranging_potion_1] id = 18734 weight = 0.008 heals = 0 -empty = "vial" examine = "1 dose of super ranging potion." [cw_super_magic_potion_4] id = 18735 weight = 0.035 excess = "cw_super_magic_potion_3" -empty = "vial" examine = "4 doses of super magic potion." [cw_super_magic_potion_3] id = 18736 weight = 0.026 excess = "cw_super_magic_potion_2" -empty = "vial" examine = "3 doses of super magic potion." [cw_super_magic_potion_2] id = 18737 weight = 0.017 excess = "cw_super_magic_potion_1" -empty = "vial" examine = "2 doses of super magic potion." [cw_super_magic_potion_1] id = 18738 weight = 0.008 heals = 0 -empty = "vial" examine = "1 dose of super magic potion." [castle_wars_flag_cape] diff --git a/data/minigame/stealing_creation/stealing_creation.items.toml b/data/minigame/stealing_creation/stealing_creation.items.toml index 6e43012e43..53835887e3 100644 --- a/data/minigame/stealing_creation/stealing_creation.items.toml +++ b/data/minigame/stealing_creation/stealing_creation.items.toml @@ -642,7 +642,6 @@ examine = "The sacred clay has transformed into a wood-like substance." id = 14207 weight = 0.5 excess = "prayer_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of Prayer restore potion." [prayer_potion_stealing_creation_5_noted] @@ -652,7 +651,6 @@ id = 14208 id = 14209 weight = 0.4 excess = "prayer_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of Prayer restore potion." [prayer_potion_stealing_creation_4_noted] @@ -662,7 +660,6 @@ id = 14210 id = 14211 weight = 0.3 excess = "prayer_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of Prayer restore potion." [prayer_potion_stealing_creation_3_noted] @@ -672,7 +669,6 @@ id = 14212 id = 14213 weight = 0.2 excess = "prayer_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of Prayer restore potion." [prayer_potion_stealing_creation_2_noted] @@ -682,7 +678,6 @@ id = 14214 id = 14215 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of Prayer restore potion." [prayer_potion_stealing_creation_1_noted] @@ -692,7 +687,6 @@ id = 14216 id = 14217 weight = 0.5 excess = "energy_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of energy potion." [energy_potion_stealing_creation_5_noted] @@ -702,7 +696,6 @@ id = 14218 id = 14219 weight = 0.4 excess = "energy_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of energy potion." [energy_potion_stealing_creation_4_noted] @@ -712,7 +705,6 @@ id = 14220 id = 14221 weight = 0.3 excess = "energy_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of energy potion." [energy_potion_stealing_creation_3_noted] @@ -722,7 +714,6 @@ id = 14222 id = 14223 weight = 0.2 excess = "energy_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of energy potion." [energy_potion_stealing_creation_2_noted] @@ -732,7 +723,6 @@ id = 14224 id = 14225 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of energy potion." [energy_potion_stealing_creation_1_noted] @@ -742,7 +732,6 @@ id = 14226 id = 14227 weight = 0.5 excess = "super_attack_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of super Attack potion." [super_attack_potion_stealing_creation_5_noted] @@ -752,7 +741,6 @@ id = 14228 id = 14229 weight = 0.4 excess = "super_attack_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of super Attack potion." [super_attack_potion_stealing_creation_4_noted] @@ -762,7 +750,6 @@ id = 14230 id = 14231 weight = 0.3 excess = "super_attack_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of super Attack potion." [super_attack_potion_stealing_creation_3_noted] @@ -772,7 +759,6 @@ id = 14232 id = 14233 weight = 0.2 excess = "super_attack_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of super Attack potion." [super_attack_potion_stealing_creation_2_noted] @@ -782,7 +768,6 @@ id = 14234 id = 14235 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of super Attack potion." [super_attack_potion_stealing_creation_1_noted] @@ -792,7 +777,6 @@ id = 14236 id = 14237 weight = 0.5 excess = "super_strength_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of super Strength potion." [super_strength_potion_stealing_creation_5_noted] @@ -802,7 +786,6 @@ id = 14238 id = 14239 weight = 0.4 excess = "super_strength_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of super Strength potion." [super_strength_potion_stealing_creation_4_noted] @@ -812,7 +795,6 @@ id = 14240 id = 14241 weight = 0.3 excess = "super_strength_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of super Strength potion." [super_strength_potion_stealing_creation_3_noted] @@ -822,7 +804,6 @@ id = 14242 id = 14243 weight = 0.2 excess = "super_strength_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of super Strength potion." [super_strength_potion_stealing_creation_2_noted] @@ -832,7 +813,6 @@ id = 14244 id = 14245 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of super Strength potion." [super_strength_potion_stealing_creation_1_noted] @@ -842,7 +822,6 @@ id = 14246 id = 14247 weight = 0.5 excess = "ranging_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of ranging potion." [ranging_potion_stealing_creation_5_noted] @@ -852,7 +831,6 @@ id = 14248 id = 14249 weight = 0.4 excess = "ranging_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of ranging potion." [ranging_potion_stealing_creation_4_noted] @@ -862,7 +840,6 @@ id = 14250 id = 14251 weight = 0.3 excess = "ranging_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of ranging potion." [ranging_potion_stealing_creation_3_noted] @@ -872,7 +849,6 @@ id = 14252 id = 14253 weight = 0.2 excess = "ranging_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of ranging potion." [ranging_potion_stealing_creation_2_noted] @@ -882,7 +858,6 @@ id = 14254 id = 14255 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of ranging potion." [ranging_potion_stealing_creation_1_noted] @@ -892,7 +867,6 @@ id = 14256 id = 14257 weight = 0.5 excess = "defence_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of Defence potion." [defence_potion_stealing_creation_5_noted] @@ -902,7 +876,6 @@ id = 14258 id = 14259 weight = 0.4 excess = "defence_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of Defence potion." [defence_potion_stealing_creation_4_noted] @@ -912,7 +885,6 @@ id = 14260 id = 14261 weight = 0.3 excess = "defence_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of Defence potion." [defence_potion_stealing_creation_3_noted] @@ -922,7 +894,6 @@ id = 14262 id = 14263 weight = 0.2 excess = "defence_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of Defence potion." [defence_potion_stealing_creation_2_noted] @@ -932,7 +903,6 @@ id = 14264 id = 14265 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of Defence potion." [defence_potion_stealing_creation_1_noted] @@ -942,7 +912,6 @@ id = 14266 id = 14267 weight = 0.5 excess = "magic_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of Magic potion." [magic_potion_stealing_creation_5_noted] @@ -952,7 +921,6 @@ id = 14268 id = 14269 weight = 0.4 excess = "magic_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of Magic potion." [magic_potion_stealing_creation_4_noted] @@ -962,7 +930,6 @@ id = 14270 id = 14271 weight = 0.3 excess = "magic_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of Magic potion." [magic_potion_stealing_creation_3_noted] @@ -972,7 +939,6 @@ id = 14272 id = 14273 weight = 0.2 excess = "magic_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of Magic potion." [magic_potion_stealing_creation_2_noted] @@ -982,7 +948,6 @@ id = 14274 id = 14275 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of Magic potion." [magic_potion_stealing_creation_1_noted] @@ -992,7 +957,6 @@ id = 14276 id = 14277 weight = 0.5 excess = "summoning_potion_stealing_creation_4" -empty = "vial" examine = "Five doses of Summoning potion." [summoning_potion_stealing_creation_5_noted] @@ -1002,7 +966,6 @@ id = 14278 id = 14279 weight = 0.4 excess = "summoning_potion_stealing_creation_3" -empty = "vial" examine = "Four doses of Summoning potion." [summoning_potion_stealing_creation_4_noted] @@ -1012,7 +975,6 @@ id = 14280 id = 14281 weight = 0.3 excess = "summoning_potion_stealing_creation_2" -empty = "vial" examine = "Three doses of Summoning potion." [summoning_potion_stealing_creation_3_noted] @@ -1022,7 +984,6 @@ id = 14282 id = 14283 weight = 0.2 excess = "summoning_potion_stealing_creation_1" -empty = "vial" examine = "Two doses of Summoning potion." [summoning_potion_stealing_creation_2_noted] @@ -1032,7 +993,6 @@ id = 14284 id = 14285 weight = 0.1 heals = 0 -empty = "vial" examine = "One dose of Summoning potion." [summoning_potion_stealing_creation_1_noted] diff --git a/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt b/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt index f5858eb4ef..46959ae4d9 100644 --- a/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt +++ b/game/src/test/kotlin/content/skill/constitution/ConsumableItemsTest.kt @@ -35,7 +35,7 @@ internal class ConsumableItemsTest : WorldTest() { val bases = mutableSetOf() for (definition in ItemDefinitions.definitions) { val id = definition.stringId - if (id.endsWith("_noted") || !id.dropLast(1).endsWith("_")) { + if (id.endsWith("_noted") || id.length < 3 || id[id.length - 2] != '_') { continue } if (id.last() !in '1'..'5') { @@ -44,17 +44,32 @@ internal class ConsumableItemsTest : WorldTest() { if (!definition.contains("heals") && !definition.contains("excess")) { continue } - val empty: String? = definition.getOrNull("empty") - if (empty == null || !empty.contains("vial")) { - continue - } bases.add(id.substringBeforeLast('_')) } - assertEquals(emptySet(), bases - PotionEffects.effects.keys, "dosed potions with no effect entry") + assertEquals(emptySet(), bases - NOT_POTIONS - PotionEffects.effects.keys, "dosed potions with no effect entry") val unmatched = PotionEffects.effects.keys.filter { base -> (1..5).none { ItemDefinitions.ids.containsKey("${base}_$it") } } assertEquals(emptyList(), unmatched, "effect entries matching no item") } + + private companion object { + /** + * Items shaped like a dosed potion without being one: food and cocktails that come in + * portions, and a second copy of an item whose name happens to end in a digit. + */ + private val NOT_POTIONS = setOf( + "blurberry_special", + "cooked_crab_meat", + "cooked_karambwan", + "drunk_dragon", + "easter_egg", + "food_class", + "fruit_blast", + "jug_of_bad_wine", + "pineapple_punch", + "short_green_guy", + ) + } } From d4cc0e0b080381965cbe7c39c787e557f02ceef1 Mon Sep 17 00:00:00 2001 From: Harley Gilpin Date: Sun, 6 Sep 2026 17:21:46 -0700 Subject: [PATCH 15/15] Play the overload animation when drinking one `anim("overload")` named an animation that was never defined, and `AnimationDefinitions.getOrNull` returns null for an unknown name, so the call silently did nothing and only the graphic showed. Define the animation next to the graphic that already carries the same name, and point the graphic call at it too. --- data/skill/herblore/herblore.anims.toml | 3 ++ .../skill/constitution/drink/Overload.kt | 2 +- .../skill/constitution/drink/OverloadTest.kt | 44 +++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 game/src/test/kotlin/content/skill/constitution/drink/OverloadTest.kt diff --git a/data/skill/herblore/herblore.anims.toml b/data/skill/herblore/herblore.anims.toml index 93cca66282..1953d54cab 100644 --- a/data/skill/herblore/herblore.anims.toml +++ b/data/skill/herblore/herblore.anims.toml @@ -1,6 +1,9 @@ [electric_shock] id = 3170 +[overload] +id = 3170 + [mixing_potion] id = 363 diff --git a/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt b/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt index ad1ab3dd07..216536cb0f 100644 --- a/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt +++ b/game/src/main/kotlin/content/skill/constitution/drink/Overload.kt @@ -75,7 +75,7 @@ class Overload : Script { repeat(5) { player.directHit(100) player.anim("overload") - player.gfx("electric_shock") + player.gfx("overload") pause(2) } } diff --git a/game/src/test/kotlin/content/skill/constitution/drink/OverloadTest.kt b/game/src/test/kotlin/content/skill/constitution/drink/OverloadTest.kt new file mode 100644 index 0000000000..74c4e17196 --- /dev/null +++ b/game/src/test/kotlin/content/skill/constitution/drink/OverloadTest.kt @@ -0,0 +1,44 @@ +package content.skill.constitution.drink + +import WorldTest +import itemOption +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import world.gregs.voidps.engine.data.definition.AnimationDefinitions +import world.gregs.voidps.engine.data.definition.GraphicDefinitions +import world.gregs.voidps.engine.entity.character.player.skill.Skill +import world.gregs.voidps.engine.entity.character.player.skill.level.Level +import world.gregs.voidps.engine.inv.add +import world.gregs.voidps.engine.inv.inventory + +internal class OverloadTest : WorldTest() { + + @Test + fun `Overload plays the shock animation for every hit`() { + val player = createPlayer(emptyTile) + player.inventory.add("overload_4") + player.experience.set(Skill.Constitution, Level.experience(99)) + player.levels.set(Skill.Constitution, 990) + val animation = AnimationDefinitions.get("overload").id + val graphic = GraphicDefinitions.get("overload").id + + player.itemOption("Drink", "overload_4") + + var animations = 0 + var graphics = 0 + repeat(12) { + if (player.visuals.animation.stand == animation) { + animations++ + } + if (player.visuals.secondaryGraphic.id == graphic || player.visuals.primaryGraphic.id == graphic) { + graphics++ + } + tick() + } + + assertEquals(5, animations) + assertEquals(5, graphics) + assertTrue(player.levels.get(Skill.Constitution) in 490..495) + } +}