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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
24 changes: 22 additions & 2 deletions game/src/main/kotlin/content/entity/Examines.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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) ->
Expand Down Expand Up @@ -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)
}
}
5 changes: 5 additions & 0 deletions game/src/main/kotlin/content/entity/combat/Combat.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions game/src/main/kotlin/content/entity/player/equip/Equipping.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.*
Expand Down Expand Up @@ -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
}
Expand Down
128 changes: 100 additions & 28 deletions game/src/main/kotlin/content/skill/melee/armour/Degradation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {

Expand All @@ -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)
}
Expand All @@ -79,6 +77,16 @@ class Degradation : Script {
}
}

private fun Player.startDegradationTimer() {
if (slots.any { slot ->
val deplete = equipment.getOrNull(slot)?.def?.getOrNull<String>("deplete")
deplete == "combat" || deplete == "equip"
}
) {
softTimers.start("degrading")
}
}

fun degrade(player: Player): Int {
var found = false
val inventory = player.equipment
Expand All @@ -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<String>("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<Int>("charges") ?: return null
if (configuredCharges > 1) {
return ItemDurability(charges(player), configuredCharges)
}

val maximum = def.getOrNull<Int>("charges_max")
?: def.getOrNull<String>("degrade")
?.let { ItemDefinitions.getOrNull(it)?.getOrNull<Int>("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<String>()
while (visited.add(current)) {
val definition = ItemDefinitions.getOrNull(current) ?: break
if (definition.getOrNull<Int>("charges") != 1) {
break
}
if (inventory.charges(this, changed.fromIndex) != 0) {
return
steps++
val next = definition.getOrNull<String>("degrade") ?: break
if (next == "destroy") {
break
}
val message: String = changed.fromItem.def.getOrNull("degrade_message") ?: return
message(message)
current = next
}
return steps
}
3 changes: 3 additions & 0 deletions game/src/main/kotlin/content/skill/melee/weapon/Weapon.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions game/src/test/kotlin/content/entity/combat/CombatTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
9 changes: 9 additions & 0 deletions game/src/test/kotlin/content/entity/player/equip/EquipTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading