diff --git a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/item/ItemEquipExtensions.kt b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/item/ItemEquipExtensions.kt index 4e31c8f192..4253020502 100644 --- a/engine/src/main/kotlin/world/gregs/voidps/engine/entity/item/ItemEquipExtensions.kt +++ b/engine/src/main/kotlin/world/gregs/voidps/engine/entity/item/ItemEquipExtensions.kt @@ -10,6 +10,8 @@ val ItemDefinition.slot: EquipSlot val Item.slot: EquipSlot get() = def.slot +fun Item.isBrokenEquipment(): Boolean = slot != EquipSlot.None && id.endsWith("_broken") + val ItemDefinition.type: EquipType get() = this["type", EquipType.None] diff --git a/game/src/main/kotlin/content/entity/Examines.kt b/game/src/main/kotlin/content/entity/Examines.kt index ce915296bd..c46ca2fe96 100644 --- a/game/src/main/kotlin/content/entity/Examines.kt +++ b/game/src/main/kotlin/content/entity/Examines.kt @@ -1,5 +1,6 @@ package content.entity +import content.skill.melee.armour.durabilityMessage import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.instruction.instruction import world.gregs.voidps.engine.client.message @@ -9,6 +10,7 @@ import world.gregs.voidps.engine.data.definition.NPCDefinitions import world.gregs.voidps.engine.data.definition.ObjectDefinitions 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.item.Item import world.gregs.voidps.network.client.instruction.ExamineItem import world.gregs.voidps.network.client.instruction.ExamineNpc import world.gregs.voidps.network.client.instruction.ExamineObject @@ -34,7 +36,16 @@ class Examines : Script { interfaceOption("Examine", "farming_equipment_store:*", ::examineItem) itemOption("Examine", inventory = "*") { (item) -> - message(item.def.getOrNull("examine") ?: return@itemOption, ChatType.ItemExamine) + showItemDetails(item.def.getOrNull("examine"), item) + } + itemOption("Check", inventory = "*") { (item) -> + showDurability(item) + } + itemOption("Check-charges", inventory = "*") { (item) -> + showDurability(item) + } + itemOption("Inspect", inventory = "*") { (item) -> + showDurability(item) } objectApproach("Examine") { (target) -> @@ -67,7 +78,16 @@ class Examines : Script { } } + private fun Player.showItemDetails(examine: String?, item: Item) { + examine?.let { message(it, ChatType.ItemExamine) } + showDurability(item) + } + + private fun Player.showDurability(item: Item) { + item.durabilityMessage(this)?.let { message(it, ChatType.ItemExamine) } + } + private fun examineItem(player: Player, option: InterfaceOption) { - player.message(option.item.def.getOrNull("examine") ?: return, ChatType.ItemExamine) + player.showItemDetails(option.item.def.getOrNull("examine"), option.item) } } diff --git a/game/src/main/kotlin/content/entity/combat/Combat.kt b/game/src/main/kotlin/content/entity/combat/Combat.kt index 99d7e9b742..74d36cb1cc 100644 --- a/game/src/main/kotlin/content/entity/combat/Combat.kt +++ b/game/src/main/kotlin/content/entity/combat/Combat.kt @@ -173,6 +173,11 @@ class Combat(val combatDefinitions: CombatDefinitions) : character.mode = EmptyMode return } + if (character is Player && character.weapon.isBrokenWeapon()) { + character.message("Your weapon is broken and cannot be used.") + character.mode = EmptyMode + return + } val attackRange = character.attackRange if (!movement.arrived(if (attackRange == 1 && character.weapon.def["weapon_type", ""] != "salamander") -1 else attackRange)) { return diff --git a/game/src/main/kotlin/content/entity/player/equip/Equipping.kt b/game/src/main/kotlin/content/entity/player/equip/Equipping.kt index cbe8ac596d..7b879d7952 100644 --- a/game/src/main/kotlin/content/entity/player/equip/Equipping.kt +++ b/game/src/main/kotlin/content/entity/player/equip/Equipping.kt @@ -1,6 +1,7 @@ package content.entity.player.equip import com.github.michaelbull.logging.InlineLogger +import content.skill.melee.weapon.isBrokenWeapon import world.gregs.voidps.cache.definition.data.ItemDefinition import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message @@ -16,6 +17,7 @@ import world.gregs.voidps.engine.entity.character.player.flagAppearance import world.gregs.voidps.engine.entity.character.player.skill.level.Level.hasRequirements import world.gregs.voidps.engine.entity.character.sound import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.item.isBrokenEquipment import world.gregs.voidps.engine.entity.item.slot import world.gregs.voidps.engine.entity.item.type import world.gregs.voidps.engine.inv.* @@ -55,6 +57,15 @@ class Equipping : Script { fun equip(player: Player, it: ItemOption) { val (item, slot) = it + if (item.isBrokenEquipment()) { + val message = if (item.isBrokenWeapon()) { + "That weapon is broken and cannot be used." + } else { + "That armour is broken and cannot be worn." + } + player.message(message) + return + } if (!player.hasRequirements(item, true)) { return } diff --git a/game/src/main/kotlin/content/skill/melee/armour/Degradation.kt b/game/src/main/kotlin/content/skill/melee/armour/Degradation.kt index b021b7a97f..d080fa5a25 100644 --- a/game/src/main/kotlin/content/skill/melee/armour/Degradation.kt +++ b/game/src/main/kotlin/content/skill/melee/armour/Degradation.kt @@ -3,7 +3,9 @@ package content.skill.melee.armour import content.entity.combat.inCombat import world.gregs.voidps.engine.Script import world.gregs.voidps.engine.client.message +import world.gregs.voidps.engine.data.definition.ItemDefinitions import world.gregs.voidps.engine.entity.character.player.Player +import world.gregs.voidps.engine.entity.item.Item import world.gregs.voidps.engine.inv.InventorySlotChanged import world.gregs.voidps.engine.inv.charges import world.gregs.voidps.engine.inv.discharge @@ -12,21 +14,14 @@ import world.gregs.voidps.engine.timer.Timer import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot /** - * Tracks and deducts charges from degradable-items. + * Tracks and deducts charges from degradable items. * * Item charges can be depleted by: - * - combat: per tick in combat - * - equip: per tick when worn + * - combat: once per degradation interval while in combat + * - equip: once per degradation interval while worn * - per_hit: per enemy hit * - per_attack: per hit dealt to an enemy * - teleport: per item use - * - * Technically, this should be: - * - 100-tick degrade cycle for ancient equipment - * - combat hits with a 90-tick cooldown for barrows equipment - * - * However, as osrs found (and changed), this allows exploitation and issues where equipment can last - * a lot longer or shorter than expected, so a more generic per-tick solution was chosen instead. */ class Degradation : Script { @@ -40,24 +35,27 @@ class Degradation : Script { init { playerSpawn { - for (slot in slots) { - val deplete: String = equipment.getOrNull(slot)?.def?.getOrNull("deplete") ?: continue - if (deplete == "combat" || deplete == "equip") { - softTimers.start("degrading") - } - } + startDegradationTimer() + } + + combatStart { + startDegradationTimer() } - timerStart("degrading") { 1 } + timerStart("degrading") { INITIAL_DEGRADATION_DELAY } timerTick("degrading") { - degrade(this) + if (degrade(this) == Timer.CANCEL) { + Timer.CANCEL + } else { + DEGRADATION_INTERVAL + } } slotChanged { - val deplete: String = it.item.def.getOrNull("deplete") ?: return@slotChanged + val deplete: String = it.item.def.getOrNull("deplete") ?: "" if (deplete == "combat" || deplete == "equip") { - softTimers.start("degrading") + startDegradationTimer() } degradeMessage(it) } @@ -79,6 +77,16 @@ class Degradation : Script { } } + private fun Player.startDegradationTimer() { + if (slots.any { slot -> + val deplete = equipment.getOrNull(slot)?.def?.getOrNull("deplete") + deplete == "combat" || deplete == "equip" + } + ) { + softTimers.start("degrading") + } + } + fun degrade(player: Player): Int { var found = false val inventory = player.equipment @@ -96,18 +104,82 @@ class Degradation : Script { } fun Player.degradeMessage(changed: InventorySlotChanged) { - val inventory = inventories.inventory(changed.inventory) val degrade: String = changed.fromItem.def.getOrNull("degrade") ?: return - if (degrade == "destroy" && changed.item.isNotEmpty()) { + if (degrade == "destroy") { + if (changed.item.isNotEmpty()) { + return + } + } else if (changed.item.id != degrade) { return } - if (changed.item.id != degrade) { - return + + val message = changed.fromItem.def.getOrNull("degrade_message") + ?: when { + degrade == "destroy" || changed.item.isEmpty() -> + "Your ${changed.fromItem.def.name.lowercase()} has run out of durability and is destroyed." + !changed.item.def.contains("degrade") -> + "Your ${changed.fromItem.def.name.lowercase()} has run out of durability and is now broken." + else -> return + } + message(message) + } + + companion object { + private const val INITIAL_DEGRADATION_DELAY = 1 + private const val DEGRADATION_INTERVAL = 100 + } +} + +data class ItemDurability( + val current: Int, + val maximum: Int, +) { + val percent: Int + get() = if (maximum <= 0) 0 else (current * 100L / maximum).toInt().coerceIn(0, 100) +} + +fun Item.durability(player: Player): ItemDurability? { + if (isEmpty() || !def.contains("charges") || !def.contains("degrade")) { + return null + } + + val configuredCharges = def.getOrNull("charges") ?: return null + if (configuredCharges > 1) { + return ItemDurability(charges(player), configuredCharges) + } + + val maximum = def.getOrNull("charges_max") + ?: def.getOrNull("degrade") + ?.let { ItemDefinitions.getOrNull(it)?.getOrNull("charges") } + ?: degradationSteps(id) + if (maximum <= 0) { + return null + } + + val current = if (configuredCharges == 1 && maximum > 1) maximum else charges(player) + return ItemDurability(current.coerceIn(0, maximum), maximum) +} + +fun Item.durabilityMessage(player: Player): String? { + val durability = durability(player) ?: return null + return "Your ${def.name.lowercase()} has ${durability.current}/${durability.maximum} charges remaining (${durability.percent}%)." +} + +private fun degradationSteps(start: String): Int { + var current = start + var steps = 0 + val visited = mutableSetOf() + while (visited.add(current)) { + val definition = ItemDefinitions.getOrNull(current) ?: break + if (definition.getOrNull("charges") != 1) { + break } - if (inventory.charges(this, changed.fromIndex) != 0) { - return + steps++ + val next = definition.getOrNull("degrade") ?: break + if (next == "destroy") { + break } - val message: String = changed.fromItem.def.getOrNull("degrade_message") ?: return - message(message) + current = next } + return steps } diff --git a/game/src/main/kotlin/content/skill/melee/weapon/Weapon.kt b/game/src/main/kotlin/content/skill/melee/weapon/Weapon.kt index f256f1ac20..216d8df3b7 100644 --- a/game/src/main/kotlin/content/skill/melee/weapon/Weapon.kt +++ b/game/src/main/kotlin/content/skill/melee/weapon/Weapon.kt @@ -23,12 +23,15 @@ import world.gregs.voidps.engine.entity.character.player.equip.equipped import world.gregs.voidps.engine.entity.character.player.skill.Skill import world.gregs.voidps.engine.entity.distanceTo import world.gregs.voidps.engine.entity.item.Item +import world.gregs.voidps.engine.entity.item.slot import world.gregs.voidps.engine.get import world.gregs.voidps.network.login.protocol.visual.update.HitSplat import world.gregs.voidps.network.login.protocol.visual.update.player.EquipSlot import world.gregs.voidps.type.random import kotlin.random.nextInt +fun Item.isBrokenWeapon(): Boolean = slot == EquipSlot.Weapon && id.endsWith("_broken") + object Weapon { val crossbows = setOf( "bronze_crossbow", diff --git a/game/src/test/kotlin/content/entity/combat/CombatTest.kt b/game/src/test/kotlin/content/entity/combat/CombatTest.kt index 59cf9a3e38..b526ccfded 100644 --- a/game/src/test/kotlin/content/entity/combat/CombatTest.kt +++ b/game/src/test/kotlin/content/entity/combat/CombatTest.kt @@ -2,6 +2,7 @@ package content.entity.combat import FakeRandom import WorldTest +import containsMessage import content.entity.combat.damageDealers import content.entity.combat.hit.hit import content.entity.effect.stun @@ -91,6 +92,19 @@ internal class CombatTest : WorldTest() { assertNotNull(FloorItems.firstOrNull(tile, "bones")) } + @Test + fun `Broken weapon cannot attack`() { + val player = createPlayer(emptyTile) + val npc = createNPC("giant_rat", emptyTile.addY(1)) + player.equipment.set(EquipSlot.Weapon.index, "dharoks_greataxe_broken") + + player.npcOption(npc, "Attack") + tick(3) + + assertTrue(npc.visuals.hits.splats.all { it == null }) + assertTrue(player.containsMessage("Your weapon is broken and cannot be used.")) + } + @Test fun `Familiar kill drops loot for its owner`() { val owner = createPlayer(emptyTile) diff --git a/game/src/test/kotlin/content/entity/player/effect/degrade/DegradeTest.kt b/game/src/test/kotlin/content/entity/player/effect/degrade/DegradeTest.kt index dd1c02092c..d914f26334 100644 --- a/game/src/test/kotlin/content/entity/player/effect/degrade/DegradeTest.kt +++ b/game/src/test/kotlin/content/entity/player/effect/degrade/DegradeTest.kt @@ -1,6 +1,8 @@ package content.entity.player.effect.degrade import WorldTest +import containsMessage +import content.skill.melee.armour.durabilityMessage import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Test import world.gregs.voidps.engine.client.instruction.handle.interactNpc @@ -222,4 +224,24 @@ class DegradeTest : WorldTest() { tick(2) assertEquals(2499, player.equipment.charges(player, EquipSlot.Shield.index)) } + + @Test + fun `Durability status reports remaining charges`() { + val player = createPlayer() + val slot = EquipSlot.Chest.index + player.equipment.set(slot, "dharoks_platebody_100", 12345) + + assertTrue(player.equipment[slot].durabilityMessage(player)!!.contains("12345/22500")) + } + + @Test + fun `Alert is sent when durability reaches zero`() { + val player = createPlayer() + val slot = EquipSlot.Hat.index + player.equipment.set(slot, "ahrims_hood_25") + assertTrue(player.equipment.discharge(player, slot)) + + assertTrue(player.containsMessage("run out of durability")) + assertEquals("ahrims_hood_broken", player.equipment[slot].id) + } } diff --git a/game/src/test/kotlin/content/entity/player/equip/EquipTest.kt b/game/src/test/kotlin/content/entity/player/equip/EquipTest.kt index 1e4ebc908d..a4ff3d0639 100644 --- a/game/src/test/kotlin/content/entity/player/equip/EquipTest.kt +++ b/game/src/test/kotlin/content/entity/player/equip/EquipTest.kt @@ -29,6 +29,15 @@ internal class EquipTest : WorldTest() { assertEquals(1, player.inventory.spaces) } + @Test + fun `Broken armour has no defensive bonus`() { + val player = createPlayer() + player.equipment.set(EquipSlot.Chest.index, "dharoks_platebody_broken") + + assertEquals(0, player["stab_defence", 0]) + assertEquals(0, player["slash_defence", 0]) + } + @Test fun `Can replace weapon with 2h if has one space`() { val player = createPlayer() diff --git a/game/src/test/kotlin/content/skill/melee/armour/CombatDegradeTest.kt b/game/src/test/kotlin/content/skill/melee/armour/CombatDegradeTest.kt index 20ffb123d1..b067e00437 100644 --- a/game/src/test/kotlin/content/skill/melee/armour/CombatDegradeTest.kt +++ b/game/src/test/kotlin/content/skill/melee/armour/CombatDegradeTest.kt @@ -51,9 +51,9 @@ internal class CombatDegradeTest : WorldTest() { assertEquals("chaotic_rapier", player.equipped(EquipSlot.Weapon).id) assertTrue(player.equipment.charges(player, EquipSlot.Weapon.index) < 30000) assertEquals("dharoks_platebody_100", player.equipped(EquipSlot.Chest).id) - assertTrue(player.equipment.charges(player, EquipSlot.Chest.index) < 22500) + assertEquals(22500, player.equipment.charges(player, EquipSlot.Chest.index)) assertEquals("statiuss_platelegs_degraded", player.equipped(EquipSlot.Legs).id) - assertTrue(player.equipment.charges(player, EquipSlot.Legs.index) < 6000) + assertEquals(6000, player.equipment.charges(player, EquipSlot.Legs.index)) assertEquals("binding_necklace", player.equipped(EquipSlot.Amulet).id) assertEquals(16, player.equipment.charges(player, EquipSlot.Amulet.index)) assertEquals("ring_of_duelling_8", player.equipped(EquipSlot.Ring).id) @@ -62,6 +62,20 @@ internal class CombatDegradeTest : WorldTest() { assertEquals(1, player.equipment.charges(player, EquipSlot.Hands.index)) } + @Test + fun `Equipped durability waits between automatic drains`() { + val player = createPlayer() + val slot = EquipSlot.Hat.index + player.equipment.set(slot, "corrupt_dragon_helm_degraded", 1500) + + tick() + assertEquals(1499, player.equipment.charges(player, slot)) + tick(99) + assertEquals(1499, player.equipment.charges(player, slot)) + tick() + assertEquals(1498, player.equipment.charges(player, slot)) + } + @Test fun `Equipment is destroyed when charges run out`() { val player = createPlayer(emptyTile) @@ -79,7 +93,7 @@ internal class CombatDegradeTest : WorldTest() { player.equipment.set(EquipSlot.Legs.index, "statiuss_platelegs_degraded") player.npcOption(npc, "Attack") - tickIf { npc.levels.get(Skill.Constitution) > 0 } + tick(2) assertEquals("chaotic_rapier_broken", player.equipped(EquipSlot.Weapon).id) assertEquals(0, player.equipment.charges(player, EquipSlot.Weapon.index))