diff --git a/.data/gamevals/varn.rscm b/.data/gamevals/varn.rscm index 937b52b27..f937e71d9 100644 --- a/.data/gamevals/varn.rscm +++ b/.data/gamevals/varn.rscm @@ -11,3 +11,6 @@ gorilla_protect_damage=9 gorilla_protect_hits=10 gorilla_miss_streak=11 gorilla_protect_switching=12 +td_shield_up=13 +td_overhead_style=14 +guaranteed_hit=15 diff --git a/.data/raw-cache/server/npcs.toml b/.data/raw-cache/server/npcs.toml index 3701a5bd2..f44eb64a9 100644 --- a/.data/raw-cache/server/npcs.toml +++ b/.data/raw-cache/server/npcs.toml @@ -4544,3 +4544,47 @@ contentGroup = "content.dark_crab_fishing_spot" moveRestrict = "NoMove" wanderRange = 0 +[[npc]] +id = "npc.tormented_demon_1" +inherit = "npc.tormented_demon_1" +huntMode = 6 +huntRange = 30 +maxRange = 30 +defaultMode = "Wander" +[npc.params] +"param.tormented_demon"=1 +"param.attackrate"=4 +"param.attack_anim"="seq.luc2_undead_demon_melee" +"param.attack_anim_stance1"="seq.luc2_undead_demon_melee" +"param.attack_anim_stance2"="seq.luc2_undead_demon_firey_balls" +"param.attack_anim_stance3"="seq.luc2_undead_demon_spare_ribs" +"param.defend_anim"="seq.luc2_undead_demon_defend" +"param.death_anim"="seq.luc2_undead_demon_death" +"param.bas_readyanim"="seq.luc2_undead_demon_ready" +"param.attack_sound_stance1"=9219 +"param.attack_sound_stance2"=9229 +"param.attack_sound_stance3"=9253 +"param.npc_attack_type"="category.attacktype_crush" + +[[npc]] +id = "npc.tormented_demon_2" +inherit = "npc.tormented_demon_2" +huntMode = 6 +huntRange = 30 +maxRange = 30 +defaultMode = "Wander" +[npc.params] +"param.tormented_demon"=1 +"param.attackrate"=4 +"param.attack_anim"="seq.luc2_undead_demon_melee" +"param.attack_anim_stance1"="seq.luc2_undead_demon_melee" +"param.attack_anim_stance2"="seq.luc2_undead_demon_firey_balls" +"param.attack_anim_stance3"="seq.luc2_undead_demon_spare_ribs" +"param.defend_anim"="seq.luc2_undead_demon_defend" +"param.death_anim"="seq.luc2_undead_demon_death" +"param.bas_readyanim"="seq.luc2_undead_demon_ready" +"param.attack_sound_stance1"=9219 +"param.attack_sound_stance2"=9229 +"param.attack_sound_stance3"=9253 +"param.npc_attack_type"="category.attacktype_crush" + diff --git a/.data/raw-cache/server/varn.toml b/.data/raw-cache/server/varn.toml index 25148aed7..18302a8a4 100644 --- a/.data/raw-cache/server/varn.toml +++ b/.data/raw-cache/server/varn.toml @@ -36,3 +36,12 @@ id = "varn.gorilla_miss_streak" [[varn]] id = "varn.gorilla_protect_switching" + +[[varn]] +id = "varn.td_shield_up" + +[[varn]] +id = "varn.td_overhead_style" + +[[varn]] +id = "varn.guaranteed_hit" diff --git a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/dsl/BossDsl.kt b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/dsl/BossDsl.kt index de2dc5c05..a8aa8f9e6 100644 --- a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/dsl/BossDsl.kt +++ b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/dsl/BossDsl.kt @@ -47,6 +47,7 @@ class BossSpecBuilder(private val npcTypes: List) { exitAfter: Int? = null, nextPhase: String? = null, idleAnim: String? = null, + attackRate: Int? = null, block: PhaseBuilder.() -> Unit, ): PhaseRef { val builder = PhaseBuilder(name).apply(block) @@ -59,6 +60,7 @@ class BossSpecBuilder(private val npcTypes: List) { exitAfter = exitAfter, nextPhase = nextPhase, idleAnim = idleAnim, + attackRate = attackRate ?: builder.attackRate, entry = builder.entry, exit = builder.exit, selector = builder.selector, @@ -358,6 +360,8 @@ class AbilityBuilder { class PhaseBuilder(private val name: String) { var entry: String? = null var exit: String? = null + + var attackRate: Int? = null var selector: Selector = Selector.WeightedRandom() internal val forceAbilities = mutableListOf() diff --git a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossCombat.kt b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossCombat.kt index a0559fd3d..89f05ee11 100644 --- a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossCombat.kt +++ b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossCombat.kt @@ -6,11 +6,11 @@ import dev.openrune.rscm.RSCMType import org.rsmod.api.bosses.spec.BossSpec import org.rsmod.api.bosses.validation.SpecValidator import org.rsmod.api.npc.access.StandardNpcAccess +import org.rsmod.api.npc.events.NpcHitEvents import org.rsmod.api.script.onAiApPlayer2 import org.rsmod.api.script.onAiOpPlayer2 import org.rsmod.api.script.onEvent import org.rsmod.api.script.onModifyNpcHit -import org.rsmod.api.npc.events.NpcHitEvents import org.rsmod.game.entity.Npc import org.rsmod.game.entity.Player import org.rsmod.game.entity.npc.NpcStateEvents @@ -97,12 +97,19 @@ object BossCombat { val encounter = deps.encounterRegistry.of(npc) if (encounter.currentPhase == null) return val tick = deps.mapClock.cycle + if (encounter.phaseEnteredTick < 0) { + encounter.phaseEnteredTick = tick + } checkAutoTransitions(this, target, encounter, tick, spec, deps) checkTriggers(this, target, spec, deps, encounter) val ticksSinceLastAttack = tick - encounter.lastAbilityTick - if (ticksSinceLastAttack < spec.stats.attackRate) return + val attackRate = + encounter.attackRateOverride + ?: encounter.currentPhase?.attackRate + ?: spec.stats.attackRate + if (ticksSinceLastAttack < attackRate) return val phase = encounter.currentPhase ?: return val abilityName = encounter.selectAbility(phase.selector, tick, target) ?: return diff --git a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossEncounter.kt b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossEncounter.kt index 3c18a6c75..ad0da227b 100644 --- a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossEncounter.kt +++ b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossEncounter.kt @@ -1,25 +1,34 @@ package org.rsmod.api.bosses.runtime +import kotlin.random.Random import org.rsmod.api.bosses.spec.* import org.rsmod.game.entity.Npc import org.rsmod.game.entity.Player -import kotlin.random.Random class BossEncounter( val npc: Npc, val spec: BossSpec, ) { var currentPhaseName: String = spec.phases.keys.firstOrNull() ?: "" - var phaseEnteredTick: Int = 0 + + var phaseEnteredTick: Int = -1 var lastAbilityTick: Int = 0 var lastAbilityName: String? = null var invulnerable: Boolean = false var damageScale: Double = 1.0 var lethalHandled: Boolean = false + /** + * Per-encounter attack-rate override (ticks between ability uses). Takes precedence over + * [PhaseSpec.attackRate] and [BossStats.attackRate]. Null by default and recreated with the + * encounter on respawn, so bosses that never set it are unaffected. + */ + var attackRateOverride: Int? = null + internal val firedTriggers = mutableSetOf() internal val firedPhaseEntries = mutableSetOf() private val cooldowns = mutableMapOf() + private val forcedTickLastFired = mutableMapOf() private var rotationCursor = 0 private var basicAttackCount = 0 private var forceAttackThreshold = -1 @@ -37,6 +46,7 @@ class BossEncounter( phaseEnteredTick = tick rotationCursor = 0 cooldowns.clear() + forcedTickLastFired.clear() basicAttackCount = 0 forceAttackThreshold = -1 @@ -55,8 +65,9 @@ class BossEncounter( for (forced in phase.forceAbilities) { if (forced.attackMin != null) continue - val elapsed = tick - phaseEnteredTick - if (elapsed > 0 && elapsed % forced.period == 0) { + val lastFired = forcedTickLastFired[forced.ability] ?: phaseEnteredTick + if (tick - lastFired >= forced.period) { + forcedTickLastFired[forced.ability] = tick return forced.ability } } diff --git a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossFx.kt b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossFx.kt index 5b13b2b9a..3cd4fbe5b 100644 --- a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossFx.kt +++ b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/BossFx.kt @@ -17,6 +17,7 @@ fun BossDeps.bossProjectile( delay: Int, travel: Int, curve: Int, + progress: Int = 0, ): ProjAnim { val proj = ProjAnim( @@ -26,7 +27,7 @@ fun BossDeps.bossProjectile( startTime = delay, endTime = delay + travel, angle = curve, - progress = 0, + progress = progress, sourceIndex = 0, targetIndex = 0, startCoord = src, @@ -72,9 +73,20 @@ fun BossDeps.lob( landTicks: Int, landGfx: Int, landGfxHeight: Int = 0, + progress: Int = 0, onLand: (Player) -> Unit, ) { - bossProjectile(spotanim, npc.coords.translate(2, 2), targetTile, startHeight, endHeight, delay, travel, curve) + bossProjectile( + spotanim, + npc.coords.translate(2, 2), + targetTile, + startHeight, + endHeight, + delay, + travel, + curve, + progress, + ) worldQueues.add(landTicks) { worldRepo.spotanimMap(SpotanimType(landGfx), targetTile, landGfxHeight) val player = targetUid.resolve(playerList) ?: return@add diff --git a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/EffectInterpreter.kt b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/EffectInterpreter.kt index f0d855aa3..5e56be82e 100644 --- a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/EffectInterpreter.kt +++ b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/runtime/EffectInterpreter.kt @@ -3,7 +3,6 @@ package org.rsmod.api.bosses.runtime import dev.openrune.ServerCacheManager import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType -import dev.openrune.types.NpcMode import dev.openrune.types.ProjAnimType import dev.openrune.types.aconverted.SpotanimType import org.rsmod.api.bosses.spec.* diff --git a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/BossSpec.kt b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/BossSpec.kt index f64b1ec00..b7c85bd8b 100644 --- a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/BossSpec.kt +++ b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/BossSpec.kt @@ -20,6 +20,12 @@ data class PhaseSpec( val entryHp: Double? = null, val transmog: String? = null, val lockMovement: Boolean = false, + /** + * Per-phase attack-rate default (ticks between ability uses). When set, it overrides + * [BossStats.attackRate] while this phase is active. A per-encounter + * [org.rsmod.api.bosses.runtime.BossEncounter.attackRateOverride] takes precedence over both. + */ + val attackRate: Int? = null, val exitAfter: Int? = null, val nextPhase: String? = null, val idleAnim: String? = null, diff --git a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/Selector.kt b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/Selector.kt index 6bb8f7e8a..f7cd23dc2 100644 --- a/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/Selector.kt +++ b/api/bosses/src/main/kotlin/org/rsmod/api/bosses/spec/Selector.kt @@ -5,8 +5,8 @@ sealed interface Selector { val entries: List = emptyList(), val noRepeatBias: Double = 0.5, ) : Selector { - constructor(vararg entries: WeightedRef, noRepeatBias: Double = 0.5) - : this(entries.toList(), noRepeatBias) + constructor(vararg entries: WeightedRef, noRepeatBias: Double = 0.5) : + this(entries.toList(), noRepeatBias) } data class Rotation(val sequence: List) : Selector diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/AccuracyOperations.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/AccuracyOperations.kt index 485e85374..d65f1a92b 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/AccuracyOperations.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/AccuracyOperations.kt @@ -26,10 +26,20 @@ public object AccuracyOperations { * Calculates the hit chance based on the attacker's [attackRoll] and the target's * [defenceRoll]. * + * @param npcAttributes When it contains [NpcAttr.GuaranteedHit] (e.g. a target that's currently + * "defenceless"), the roll is skipped entirely and this returns a guaranteed `100%`. Only + * meaningful for player-vs-npc accuracy; PvP/NvN/NvP callers can omit it. * @return An integer between `0` and `10,000`, where `0` represents a `0%` hit chance, `1` * represents a `0.01%` hit chance, and `10,000` represents a `100%` hit chance. */ - public fun calculateHitChance(attackRoll: Int, defenceRoll: Int): Int { + public fun calculateHitChance( + attackRoll: Int, + defenceRoll: Int, + npcAttributes: EnumSet = EnumSet.noneOf(CombatNpcAttributes::class.java), + ): Int { + if (NpcAttr.GuaranteedHit in npcAttributes) { + return HIT_CHANCE_SCALE + } return calculateHitRoll(attackRoll, defenceRoll) } diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/magic/PvNMagicAccuracy.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/magic/PvNMagicAccuracy.kt index a79bd51f8..01477dae5 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/magic/PvNMagicAccuracy.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/magic/PvNMagicAccuracy.kt @@ -42,6 +42,7 @@ constructor( return computeSpellHitChance( source = player, target = targetType, + npc = target, spell = spell, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, @@ -64,12 +65,13 @@ constructor( targetWeaknessPercent: Int, spellbook: Spellbook?, usedSunfireRune: Boolean, + npc: Npc? = null, ): Int { val spellAttributes = magicAttributes.spellCollect(source, spell, spellbook, usedSunfireRune, random) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val attackRoll = computeSpellAttackRoll( @@ -90,7 +92,7 @@ constructor( ) val defenceRoll = modifySpellDefenceRoll(baseDefenceRoll, spellAttributes) - return AccuracyOperations.calculateHitChance(attackRoll, defenceRoll) + return AccuracyOperations.calculateHitChance(attackRoll, defenceRoll, npcAttributes) } public fun computeSpellAttackRoll( @@ -119,6 +121,7 @@ constructor( computeStaffHitChance( source = player, target = target.visType, + npc = target, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -136,11 +139,12 @@ constructor( targetMagic: Int, attackStyle: MagicAttackStyle?, specialMultiplier: Double, + npc: Npc? = null, ): Int { val staffAttributes = magicAttributes.staffCollect(source, random) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val baseAttackRoll = computeStaffAttackRoll(source, attackStyle, staffAttributes, npcAttributes) @@ -157,7 +161,7 @@ constructor( ) val defenceRoll = modifyStaffDefenceRoll(baseDefenceRoll, staffAttributes) - return AccuracyOperations.calculateHitChance(attackRoll, defenceRoll) + return AccuracyOperations.calculateHitChance(attackRoll, defenceRoll, npcAttributes) } public fun computeStaffAttackRoll( diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvNMeleeAccuracy.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvNMeleeAccuracy.kt index e3d7ee1a2..b55e0f7c6 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvNMeleeAccuracy.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvNMeleeAccuracy.kt @@ -36,6 +36,7 @@ constructor( computeHitChance( source = player, target = target.visType, + npc = target, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -55,11 +56,12 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specialMultiplier: Double, + npc: Npc? = null, ): Int { val meleeAttributes = meleeAttributes.collect(source, attackType) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val baseAttackRoll = computeAttackRoll(source, attackType, attackStyle, meleeAttributes, npcAttributes) @@ -75,7 +77,7 @@ constructor( npcAttributes = npcAttributes, ) - val hitChance = AccuracyOperations.calculateHitChance(attackRoll, defenceRoll) + val hitChance = AccuracyOperations.calculateHitChance(attackRoll, defenceRoll, npcAttributes) return MeleeAccuracyOperations.modifyHitChance( hitChance = hitChance, attackRoll = attackRoll, diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/multi/PvNMultiStyleAccuracy.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/multi/PvNMultiStyleAccuracy.kt index 49758fba3..d735ad014 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/multi/PvNMultiStyleAccuracy.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/multi/PvNMultiStyleAccuracy.kt @@ -42,6 +42,7 @@ constructor( return computeMagicalMeleeHitChance( source = player, target = target.visType, + npc = target, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -55,6 +56,7 @@ constructor( private fun computeMagicalMeleeHitChance( source: Player, target: NpcServerType, + npc: Npc?, targetDefence: Int, targetCurrHp: Int, targetMaxHp: Int, @@ -66,7 +68,7 @@ constructor( val meleeAttributes = meleeAttributes.collect(source, attackType) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val baseAttackRoll = melee.computeAttackRoll(source, attackType, attackStyle, meleeAttributes, npcAttributes) @@ -96,6 +98,7 @@ constructor( return computeRangedMeleeHitChance( source = player, target = target.visType, + npc = target, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -109,6 +112,7 @@ constructor( private fun computeRangedMeleeHitChance( source: Player, target: NpcServerType, + npc: Npc?, targetDefence: Int, targetCurrHp: Int, targetMaxHp: Int, @@ -120,7 +124,7 @@ constructor( val meleeAttributes = meleeAttributes.collect(source, attackType) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val baseAttackRoll = melee.computeAttackRoll(source, attackType, attackStyle, meleeAttributes, npcAttributes) @@ -149,6 +153,7 @@ constructor( return computeRangedMagicHitChance( source = player, target = target.visType, + npc = target, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -161,6 +166,7 @@ constructor( private fun computeRangedMagicHitChance( source: Player, target: NpcServerType, + npc: Npc?, targetDefence: Int, targetCurrHp: Int, targetMaxHp: Int, @@ -169,7 +175,7 @@ constructor( specialMultiplier: Double, ): Int { val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) // TODO(combat): Should we use `computeSpellAttackRoll` instead to take elemental weakness // into account? @@ -203,6 +209,7 @@ constructor( return computeMagicalRangedHitChance( source = player, target = targetType, + npc = target, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -217,6 +224,7 @@ constructor( private fun computeMagicalRangedHitChance( source: Player, target: NpcServerType, + npc: Npc?, targetDefence: Int, targetCurrHp: Int, targetMaxHp: Int, @@ -229,7 +237,7 @@ constructor( val rangeAttributes = rangedAttributes.collect(source, attackType, attackStyle) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val baseAttackRoll = ranged.computeAttackRoll( diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/ranged/PvNRangedAccuracy.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/ranged/PvNRangedAccuracy.kt index 345ddab11..37a4ae5f8 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/ranged/PvNRangedAccuracy.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/ranged/PvNRangedAccuracy.kt @@ -41,6 +41,7 @@ constructor( return computeHitChance( source = player, target = targetType, + npc = target, targetDefence = target.defenceLvl, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -65,11 +66,12 @@ constructor( attackStyle: RangedAttackStyle?, blockType: RangedAttackType?, specialMultiplier: Double, + npc: Npc? = null, ): Int { val rangeAttributes = rangedAttributes.collect(source, attackType, attackStyle) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val baseAttackRoll = computeAttackRoll( @@ -92,7 +94,7 @@ constructor( npcAttributes = npcAttributes, ) - return AccuracyOperations.calculateHitChance(attackRoll, defenceRoll) + return AccuracyOperations.calculateHitChance(attackRoll, defenceRoll, npcAttributes) } public fun computeAttackRoll( diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/CombatNpcAttributes.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/CombatNpcAttributes.kt index dede0d8c2..c46308355 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/CombatNpcAttributes.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/CombatNpcAttributes.kt @@ -19,6 +19,10 @@ public enum class CombatNpcAttributes { Shade, Vampyre, TormentedDemonUnshielded, + GuaranteedHit, + TormentedDemonOverheadMelee, + TormentedDemonOverheadRanged, + TormentedDemonOverheadMagic, CorporealBeast, Xerician, Amascut, diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatMeleeAttributeCollector.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatMeleeAttributeCollector.kt index 71593123d..97ac30fbd 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatMeleeAttributeCollector.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatMeleeAttributeCollector.kt @@ -50,7 +50,7 @@ public class CombatMeleeAttributeCollector { } val weapon = player.righthand - if (weapon.isType("obj.arclight")) { + if (weapon.isAnyType("obj.arclight", "obj.emberlight")) { attributes += CombatMeleeAttributes.Arclight } else if (weapon.isType("obj.bone_claws")) { attributes += CombatMeleeAttributes.BurningClaws diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatNpcAttributeCollector.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatNpcAttributeCollector.kt index 2dd331a35..79e6729f0 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatNpcAttributeCollector.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/attributes/collector/CombatNpcAttributeCollector.kt @@ -5,10 +5,17 @@ import java.util.EnumSet import org.rsmod.api.combat.formulas.attributes.CombatNpcAttributes import org.rsmod.api.config.constants import org.rsmod.api.config.refs.params +import org.rsmod.game.entity.Npc public class CombatNpcAttributeCollector { + /** + * @param npc the live npc being attacked, when available. Passing it lets branches that depend on + * per-fight runtime state (e.g. the Tormented Demon fire shield) read npc vars rather than only + * static npc-type params. Callers that only have a [NpcServerType] pass `null`. + */ public fun collect( type: NpcServerType, + npc: Npc?, currHp: Int, maxHp: Int, slayerTask: Boolean, @@ -100,8 +107,19 @@ public class CombatNpcAttributeCollector { attributes += CombatNpcAttributes.Vampyre } - if (type.param(params.tormented_demon) != 0 && !type.param(params.td_shield_active)) { - attributes += CombatNpcAttributes.TormentedDemonUnshielded + if (type.param(params.tormented_demon) != 0 && npc != null) { + if (npc.vars["varn.td_shield_up"] == 0) { + attributes += CombatNpcAttributes.TormentedDemonUnshielded + } + when (npc.vars["varn.td_overhead_style"]) { + 1 -> attributes += CombatNpcAttributes.TormentedDemonOverheadMelee + 2 -> attributes += CombatNpcAttributes.TormentedDemonOverheadRanged + 3 -> attributes += CombatNpcAttributes.TormentedDemonOverheadMagic + } + } + + if (npc != null && npc.vars["varn.guaranteed_hit"] == 1) { + attributes += CombatNpcAttributes.GuaranteedHit } if (type.isType("npc.corp_beast")) { diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/MagicMaxHitOperations.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/MagicMaxHitOperations.kt index 226b10bae..c801653d9 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/MagicMaxHitOperations.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/MagicMaxHitOperations.kt @@ -198,7 +198,10 @@ public object MagicMaxHitOperations { } if (NpcAttr.TormentedDemonUnshielded in npcAttributes) { - val bonusDamage = max(0, (attackRate * attackRate) - 16) + var bonusDamage = max(0, (attackRate * attackRate) - 16) + if (NpcAttr.TormentedDemonOverheadMagic in npcAttributes) { + bonusDamage /= 3 + } modifiedMax += bonusDamage } diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/PvNMagicMaxHit.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/PvNMagicMaxHit.kt index 44c020d3a..52865727a 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/PvNMagicMaxHit.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/magic/PvNMagicMaxHit.kt @@ -62,6 +62,7 @@ constructor( computeSpellMaxHit( source = player, target = targetType, + npc = target, spell = spell, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, @@ -86,12 +87,13 @@ constructor( attackRate: Int, spellbook: Spellbook?, usedSunfireRune: Boolean, + npc: Npc? = null, ): IntRange { val spellAttributes = magicAttributes.spellCollect(source, spell, spellbook, usedSunfireRune, random) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val modifiedDamage = computeSpellModifiedDamage(source, baseMaxHit, spellAttributes, npcAttributes) @@ -146,6 +148,7 @@ constructor( computeStaffMaxHit( source = player, target = target.visType, + npc = target, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, baseMaxHit = baseMaxHit, @@ -162,11 +165,12 @@ constructor( targetMaxHp: Int, baseMaxHit: Int, specialMultiplier: Double, + npc: Npc? = null, ): Int { val staffAttributes = magicAttributes.staffCollect(source, random) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val modifiedDamage = computeStaffModifiedDamage(source, baseMaxHit, staffAttributes, npcAttributes) diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/MeleeMaxHitOperations.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/MeleeMaxHitOperations.kt index cac452ba8..0a47e0331 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/MeleeMaxHitOperations.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/MeleeMaxHitOperations.kt @@ -171,7 +171,10 @@ public object MeleeMaxHitOperations { val unshieldedTormentedDemon = MeleeAttr.Crush in meleeAttributes && NpcAttr.TormentedDemonUnshielded in npcAttributes if (unshieldedTormentedDemon) { - val bonusDamage = max(0, (attackRate * attackRate) - 16) + var bonusDamage = max(0, (attackRate * attackRate) - 16) + if (NpcAttr.TormentedDemonOverheadMelee in npcAttributes) { + bonusDamage /= 3 + } modified += bonusDamage } diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/PvNMeleeMaxHit.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/PvNMeleeMaxHit.kt index 74fbff411..a4e15b2eb 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/PvNMeleeMaxHit.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/melee/PvNMeleeMaxHit.kt @@ -53,6 +53,7 @@ constructor( computeMaxHit( source = player, target = target.visType, + npc = target, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, attackType = attackType, @@ -73,12 +74,13 @@ constructor( attackStyle: MeleeAttackStyle?, specialMultiplier: Double, roundUp: Boolean = false, + npc: Npc? = null, ): Int { val meleeAttributes = meleeAttributes.collect(source, attackType) addProcAttributes(meleeAttributes) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val modifiedDamage = computeModifiedDamage(source, attackStyle, meleeAttributes, npcAttributes) diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvNRangedMaxHit.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvNRangedMaxHit.kt index 765698b04..34c848de0 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvNRangedMaxHit.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvNRangedMaxHit.kt @@ -55,6 +55,7 @@ constructor( computeMaxHit( source = player, target = targetType, + npc = target, targetCurrHp = target.hitpoints, targetMaxHp = target.baseHitpointsLvl, targetMagic = targetMagic, @@ -77,11 +78,12 @@ constructor( attackStyle: RangedAttackStyle?, specialMultiplier: Double, boltSpecDamage: Int, + npc: Npc? = null, ): Int { val rangeAttributes = rangedAttributes.collect(source, attackType, attackStyle) val slayerTask = target.isSlayerTask(source) - val npcAttributes = npcAttributes.collect(target, targetCurrHp, targetMaxHp, slayerTask) + val npcAttributes = npcAttributes.collect(target, npc, targetCurrHp, targetMaxHp, slayerTask) val modifiedDamage = computeModifiedDamage(source, targetMagic, attackStyle, rangeAttributes, npcAttributes) diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/RangedMaxHitOperations.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/RangedMaxHitOperations.kt index ff72ac1d7..46a4bc7e6 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/RangedMaxHitOperations.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/RangedMaxHitOperations.kt @@ -143,7 +143,10 @@ public object RangedMaxHitOperations { val unshieldedTormentedDemon = RangeAttr.Heavy in rangeAttributes && NpcAttr.TormentedDemonUnshielded in npcAttributes if (unshieldedTormentedDemon) { - val bonusDamage = max(0, (attackRate * attackRate) - 16) + var bonusDamage = max(0, (attackRate * attackRate) - 16) + if (NpcAttr.TormentedDemonOverheadRanged in npcAttributes) { + bonusDamage /= 3 + } modified += bonusDamage } diff --git a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspNpcInfo.kt b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspNpcInfo.kt index 02b208615..3784a9d85 100644 --- a/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspNpcInfo.kt +++ b/api/net/src/main/kotlin/org/rsmod/api/net/rsprot/RspNpcInfo.kt @@ -39,6 +39,14 @@ class RspNpcInfo(val rspAvatar: NpcAvatar) : NpcInfoProtocol { rspAvatar.extendedInfo.setTransmogrification(originalType) } + override fun setBodyModel(model: Int) { + rspAvatar.extendedInfo.setBodyCustomisation(listOf(model), emptyList(), emptyList()) + } + + override fun resetBodyModel() { + rspAvatar.extendedInfo.resetBodyCustomisations() + } + override fun showHeadbar(headbar: Headbar) { rspAvatar.extendedInfo.addHeadBar( sourceIndex = if (headbar.isNoSource) -1 else headbar.sourceSlot, diff --git a/content/bosses/tormented-demon/build.gradle.kts b/content/bosses/tormented-demon/build.gradle.kts new file mode 100644 index 000000000..b0684a230 --- /dev/null +++ b/content/bosses/tormented-demon/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + id("base-conventions") +} + +dependencies { + implementation(projects.api.bosses) + implementation(projects.api.pluginCommons) + implementation(projects.api.combat.combatCommons) + implementation(projects.api.route) +} diff --git a/content/bosses/tormented-demon/src/main/kotlin/org/rsmod/content/bosses/tormenteddemon/TormentedDemon.kt b/content/bosses/tormented-demon/src/main/kotlin/org/rsmod/content/bosses/tormenteddemon/TormentedDemon.kt new file mode 100644 index 000000000..703ed18fa --- /dev/null +++ b/content/bosses/tormented-demon/src/main/kotlin/org/rsmod/content/bosses/tormenteddemon/TormentedDemon.kt @@ -0,0 +1,433 @@ +package org.rsmod.content.bosses.tormenteddemon + +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.aconverted.SpotanimType +import jakarta.inject.Inject +import org.rsmod.api.bosses.dsl.* +import org.rsmod.api.bosses.runtime.BossCombat +import org.rsmod.api.bosses.runtime.BossDeps +import org.rsmod.api.bosses.runtime.BossPluginScript +import org.rsmod.api.bosses.runtime.encounter +import org.rsmod.api.bosses.runtime.lob +import org.rsmod.api.bosses.runtime.suppressAttacks +import org.rsmod.api.bosses.spec.Condition +import org.rsmod.api.bosses.spec.Effect +import org.rsmod.api.bosses.spec.ProjectileConfig +import org.rsmod.api.combat.commons.CombatEffects +import org.rsmod.api.combat.commons.player.finishNpcHit +import org.rsmod.api.player.isValidTarget +import org.rsmod.api.player.vars.setActiveMoveSpeed +import org.rsmod.api.player.vars.varMoveSpeed +import org.rsmod.api.route.RouteFactory +import org.rsmod.api.route.walkTo +import org.rsmod.api.script.onEvent +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.npc.NpcStateEvents +import org.rsmod.game.entity.npc.NpcUid +import org.rsmod.game.hit.HitBuilder +import org.rsmod.game.hit.HitType +import org.rsmod.game.movement.MoveSpeed +import org.rsmod.map.CoordGrid +import org.rsmod.plugin.scripts.ScriptContext +import org.rsmod.routefinder.flag.CollisionFlag + +class TormentedDemon +@Inject +constructor(deps: BossDeps, private val routeFactory: RouteFactory) : BossPluginScript(deps) { + + private val demonTypeIds: Set by lazy { + setOf("npc.tormented_demon_1".asRSCM(RSCMType.NPC), "npc.tormented_demon_2".asRSCM(RSCMType.NPC)) + } + + private val defencelessModelByType: Map by lazy { + mapOf( + "npc.tormented_demon_1".asRSCM(RSCMType.NPC) to DEFENCELESS_MODEL_1, + "npc.tormented_demon_2".asRSCM(RSCMType.NPC) to DEFENCELESS_MODEL_2, + ) + } + + private val fights = mutableMapOf() + + private fun fightFor(npc: Npc): TdFight = fights.getOrPut(npc.uid) { TdFight() } + + private fun markFightStarted(fight: TdFight) { + if (fight.defencelessCycleStart == 0) fight.defencelessCycleStart = deps.mapClock.cycle + } + + override fun ScriptContext.startup() { + BossCombat.register(this, spec, deps, onModifyHit = { onDemonHit(npc, hit) }) + + for (typeId in demonTypeIds) { + onEvent(typeId) { + npc.vars["varn.td_shield_up"] = 1 + initializeOverheadPrayer(npc) + } + } + onEvent { + if (npc.id in demonTypeIds) { + fights.remove(npc.uid) + npc.vars["varn.td_shield_up"] = 1 + npc.vars["varn.guaranteed_hit"] = 0 + if (DEFENCELESS_MODEL_SWAP_ENABLED) npc.resetBodyModel() + initializeOverheadPrayer(npc) + } + } + onEvent { + if (npc.id in demonTypeIds) fights.remove(npc.uid) + } + + deps.extensionRegistry.register("td.post_attack") { _, npc, _, _ -> postAttack(npc) } + deps.extensionRegistry.register("td.fire_bomb") { _, npc, target, _ -> fireBomb(npc, target) } + } + + override val spec = + boss("npc.tormented_demon_1", "npc.tormented_demon_2") { + stats( + attackRate = TormentedDemonMechanics.SOLO_ATTACK_RATE, + aggressionRadius = AGGRO_RANGE, + ) + + val melee = + ability("melee") { + anim("seq.luc2_undead_demon_melee") + spotanim("spotanim.luc2_undead_demon_melee_spot") + hit { + damage(0..MELEE_MAX_HIT).roll() + type(Melee) + } + include(external("td.post_attack")) + } + + val ranged = + ability("ranged") { + anim("seq.luc2_undead_demon_spare_ribs") + projectile( + spotanim = "spotanim.luc2_rib_bone_shard_projectile", + config = + ProjectileConfig( + startHeight = 400, + endHeight = 120, + angle = 10, + progress = 200, + ), + hit = Effect.Hit(damage = Roll(0..RANGED_MAX_HIT), type = Ranged), + ) + include(external("td.post_attack")) + } + + val magic = + ability("magic") { + anim("seq.luc2_undead_demon_firey_balls") + projectile( + spotanim = "spotanim.luc2_undead_demon_fireball_proj", + config = + ProjectileConfig( + startHeight = 344, + endHeight = 120, + angle = 0, + progress = 160, + ), + hit = Effect.Hit(damage = Roll(0..MAGIC_MAX_HIT), type = Magic), + ) + include(external("td.post_attack")) + } + + val fireBomb = + ability("fire_bomb") { + anim("seq.luc2_undead_demon_explosion_fire") + include(external("td.fire_bomb")) + } + + phase(PHASE_MELEE) { + forceEvery(FIRE_BOMB_PERIOD, fireBomb) + weightedSelectorRandom { + +random(melee, weight = 1, requires = WithinMeleeRange) + +random(ranged, weight = 1, requires = Condition.Not(WithinMeleeRange)) + +random(magic, weight = 1, requires = Condition.Not(WithinMeleeRange)) + } + } + phase(PHASE_RANGED) { + forceEvery(FIRE_BOMB_PERIOD, fireBomb) + weightedSelectorRandom { +random(ranged, weight = 1) } + } + phase(PHASE_MAGIC) { + forceEvery(FIRE_BOMB_PERIOD, fireBomb) + weightedSelectorRandom { +random(magic, weight = 1) } + } + } + + private fun fireBomb(npc: Npc, target: Player) { + if (!target.isValidTarget()) return + val fight = fightFor(npc) + markFightStarted(fight) + + val primaryTile = target.coords + val secondaryTile = randomAdjacentWalkableTile(primaryTile) ?: primaryTile + val targetUid = target.uid + + CombatEffects.freeze(target, FIRE_BOMB_BIND_TICKS) + target.spotanim("spotanim.entangle_impact", height = 124) + val restoreSpeed = target.varMoveSpeed + target.setActiveMoveSpeed(MoveSpeed.Walk) + deps.worldQueues.add(FIRE_BOMB_BIND_TICKS) { + if (target.isValidTarget()) target.setActiveMoveSpeed(restoreSpeed) + } + + for (landingTile in listOf(primaryTile, secondaryTile)) { + deps.worldRepo.spotanimMap( + SpotanimType(TELEGRAPH_SHADOW), + landingTile, + delay = FIRE_BOMB_TELEGRAPH_DELAY, + ) + deps.lob( + npc = npc, + targetTile = landingTile, + targetUid = targetUid, + spotanim = PROJ_FIRE_BOMB, + startHeight = 344, + endHeight = 0, + delay = FIRE_BOMB_PROJ_DELAY, + travel = FIRE_BOMB_PROJ_TRAVEL, + curve = FIRE_BOMB_PROJ_ANGLE, + landTicks = FIRE_BOMB_LAND_TICKS, + landGfx = SPOT_EXPLOSION, + progress = FIRE_BOMB_PROJ_PROGRESS, + ) { player -> + if (player.coords == landingTile) { + val damage = FIRE_BOMB_MIN_DAMAGE + deps.random.of(FIRE_BOMB_DAMAGE_SPREAD) + player.finishNpcHit(npc, 1, HitType.Magic, damage, deps.playerHitModifier) + } + } + } + + fight.defenceless = false + fight.defencelessCycleStart = deps.mapClock.cycle + if (DEFENCELESS_MODEL_SWAP_ENABLED) npc.resetBodyModel() + dropShield(npc, fight) + + val encounter = deps.encounter(npc) + val otherStyles = STYLE_PHASES.filter { it != encounter.currentPhaseName } + val nextStyle = otherStyles[deps.random.of(otherStyles.size)] + encounter.transitionTo(nextStyle, deps.mapClock.cycle) + applyStyleRange(npc, nextStyle) + if (nextStyle != PHASE_MELEE) { + retreatFromMelee(npc, target) + } + } + + private fun applyStyleRange(npc: Npc, style: String) { + npc.apRangeOverride = if (style == PHASE_MELEE) null else RANGED_MAGIC_AP_RANGE + } + + private fun retreatFromMelee(npc: Npc, target: Player) { + if (!npc.isWithinDistance(target, MELEE_RANGE_TILES)) return + val dx = retreatOffset(npc.coords.x - target.coords.x) + val dz = retreatOffset(npc.coords.z - target.coords.z) + val dest = npc.coords.translate(dx * RETREAT_DISTANCE, dz * RETREAT_DISTANCE) + npc.walkTo(routeFactory, dest) + } + + private fun retreatOffset(delta: Int): Int = + when { + delta > 0 -> 1 + delta < 0 -> -1 + else -> if (deps.random.of(2) == 0) -1 else 1 + } + + private fun randomAdjacentWalkableTile(center: CoordGrid): CoordGrid? { + val candidates = + ADJACENT_OFFSETS.map { (dx, dz) -> center.translate(dx, dz) }.filter(::isWalkable) + if (candidates.isEmpty()) return null + return candidates[deps.random.of(candidates.size)] + } + + private fun isWalkable(coord: CoordGrid): Boolean { + val flags = deps.collision[coord.x, coord.z, coord.level] + return flags and (CollisionFlag.BLOCK_WALK or CollisionFlag.LOC) == 0 + } + + private fun postAttack(npc: Npc) { + val fight = fightFor(npc) + markFightStarted(fight) + updateDefenceless(npc, fight) + } + + private fun dropShield(npc: Npc, fight: TdFight) { + npc.vars["varn.td_shield_up"] = 0 + npc.spotanim("spotanim.luc2_undead_demon_explosion_fire_spot", slot = SHIELD_SPOT_SLOT) + updateStyleImmunity(npc, null) + updateGuaranteedHit(npc, fight) + } + + private fun updateGuaranteedHit(npc: Npc, fight: TdFight) { + val guaranteed = fight.defenceless || npc.vars["varn.td_shield_up"] == 0 + npc.vars["varn.guaranteed_hit"] = if (guaranteed) 1 else 0 + } + + private fun initializeOverheadPrayer(npc: Npc) { + val style = HitType.Melee + fightFor(npc).overheadStyle = style + npc.vars["varn.td_overhead_style"] = overheadStyleCode(style) + updateStyleImmunity(npc, style) + val index = headIconIndex(style) + if (index != null) { + npc.setHeadIcon(HEADICON_SLOT, HEADICON_GRAPHIC, index) + } else { + npc.clearHeadIcon(HEADICON_SLOT) + } + } + + private fun updateStyleImmunity(npc: Npc, activeStyle: HitType?) { + npc.vars["varn.immune_melee"] = if (activeStyle == HitType.Melee) 1 else 0 + npc.vars["varn.immune_ranged"] = if (activeStyle == HitType.Ranged) 1 else 0 + npc.vars["varn.immune_magic"] = if (activeStyle == HitType.Magic) 1 else 0 + } + + private fun armStyleImmunityNextTick(npc: Npc, activeStyle: HitType?) { + val uid = npc.uid + deps.worldQueues.add(1) { + if (npc.uid == uid) { + updateStyleImmunity(npc, activeStyle) + } + } + } + + private fun updateDefenceless(npc: Npc, fight: TdFight) { + if (fight.defenceless || fight.defencelessCycleStart == 0) return + if (deps.mapClock.cycle - fight.defencelessCycleStart >= DEFENCELESS_DELAY_TICKS) { + fight.defenceless = true + updateGuaranteedHit(npc, fight) + npc.spotanim("spotanim.luc2_undead_accuracy_debuff", slot = DEFENCELESS_SPOT_SLOT) + if (DEFENCELESS_MODEL_SWAP_ENABLED) { + defencelessModelByType[npc.id]?.let { npc.setBodyModel(it) } + } + } + } + + private fun onDemonHit(npc: Npc, hit: HitBuilder) { + if (!hit.isFromPlayer) return + val fight = fightFor(npc) + markFightStarted(fight) + val style = hit.type + val shieldWasUp = npc.vars["varn.td_shield_up"] == 1 + + if (!fight.firstHitTaken) { + fight.firstHitTaken = true + dropShield(npc, fight) + } else if (!shieldWasUp) { + npc.vars["varn.td_shield_up"] = 1 + npc.spotanim("spotanim.luc2_undead_demon_shield_restore_spot", slot = SHIELD_SPOT_SLOT) + armStyleImmunityNextTick(npc, fight.overheadStyle) + updateGuaranteedHit(npc, fight) + } else { + npc.spotanim("spotanim.luc2_undead_demon_shield_spot", slot = SHIELD_SPOT_SLOT) + } + + val blockedByOverhead = shieldWasUp && fight.overheadStyle == style + fight.damageSinceSwap += if (blockedByOverhead) 0 else hit.damage + fight.lastStyleHit = style + + if (TormentedDemonMechanics.shouldSwapPrayer(fight.damageSinceSwap)) { + fight.overheadStyle = style + fight.damageSinceSwap = 0 + npc.vars["varn.td_overhead_style"] = overheadStyleCode(style) + val index = headIconIndex(style) + if (index != null) { + npc.setHeadIcon(HEADICON_SLOT, HEADICON_GRAPHIC, index) + } else { + npc.clearHeadIcon(HEADICON_SLOT) + } + armStyleImmunityNextTick(npc, style) + deps.suppressAttacks(npc, PRAYER_STALL_TICKS) + } + } + + private fun headIconIndex(style: HitType): Int? = + when (style) { + HitType.Melee -> 0 + HitType.Ranged -> 1 + HitType.Magic -> 2 + else -> null + } + + private fun overheadStyleCode(style: HitType): Int = + when (style) { + HitType.Melee -> 1 + HitType.Ranged -> 2 + HitType.Magic -> 3 + else -> 0 + } + + private class TdFight { + var firstHitTaken: Boolean = false + var overheadStyle: HitType? = null + var lastStyleHit: HitType? = null + var damageSinceSwap: Int = 0 + var defencelessCycleStart: Int = 0 + var defenceless: Boolean = false + } + + private companion object { + private const val PHASE_MELEE = "style_melee" + private const val PHASE_RANGED = "style_ranged" + private const val PHASE_MAGIC = "style_magic" + private val STYLE_PHASES = listOf(PHASE_MELEE, PHASE_RANGED, PHASE_MAGIC) + private const val RANGED_MAGIC_AP_RANGE = 7 + private const val MELEE_RANGE_TILES = 1 + private const val RETREAT_DISTANCE = 3 + + private const val AGGRO_RANGE = 8 + + private const val PRAYER_STALL_TICKS = 6 + private const val DEFENCELESS_DELAY_TICKS = 30 + private const val DEFENCELESS_MODEL_1 = 55475 + private const val DEFENCELESS_MODEL_2 = 55474 + + /** + * [Npc.setBodyModel]/[Npc.resetBodyModel] (RSProt's `setBodyCustomisation` extended info + * block) crashes both the real client and rsprox's decoder on revision 240 with an + * `IndexOutOfBoundsException` in `decodeBodyCustomisationV3`. Keep the call sites in place + * but disabled until this is fixed upstream. + */ + private const val DEFENCELESS_MODEL_SWAP_ENABLED = false + + private const val HEADICON_SLOT = 0 + private const val HEADICON_GRAPHIC = 440 + + private const val SHIELD_SPOT_SLOT = 4 + private const val DEFENCELESS_SPOT_SLOT = 5 + + private const val FIRE_BOMB_PERIOD = 60 + private const val FIRE_BOMB_BIND_TICKS = 2 + + private const val FIRE_BOMB_LAND_TICKS = 4 + private const val FIRE_BOMB_MIN_DAMAGE = 20 + private const val FIRE_BOMB_DAMAGE_SPREAD = 6 + + private const val FIRE_BOMB_PROJ_DELAY = 45 + private const val FIRE_BOMB_PROJ_TRAVEL = 70 + private const val FIRE_BOMB_PROJ_ANGLE = 30 + private const val FIRE_BOMB_PROJ_PROGRESS = 128 + + private const val MELEE_MAX_HIT = 30 + private const val RANGED_MAX_HIT = 30 + private const val MAGIC_MAX_HIT = 30 + + private const val PROJ_FIRE_BOMB = 2855 + private const val SPOT_EXPLOSION = 2856 + private const val TELEGRAPH_SHADOW = 1446 + private const val FIRE_BOMB_TELEGRAPH_DELAY = 30 + + private val ADJACENT_OFFSETS = + listOf(-1 to -1, 0 to -1, 1 to -1, -1 to 0, 1 to 0, -1 to 1, 0 to 1, 1 to 1) + } +} + +internal object TormentedDemonMechanics { + const val SOLO_ATTACK_RATE: Int = 4 + const val PRAYER_SWAP_DAMAGE: Int = 150 + + fun shouldSwapPrayer(damageSinceSwap: Int): Boolean = damageSinceSwap >= PRAYER_SWAP_DAMAGE +} diff --git a/content/bosses/tormented-demon/src/main/kotlin/org/rsmod/content/bosses/tormenteddemon/TormentedDemonCraftingScript.kt b/content/bosses/tormented-demon/src/main/kotlin/org/rsmod/content/bosses/tormenteddemon/TormentedDemonCraftingScript.kt new file mode 100644 index 000000000..3069431f0 --- /dev/null +++ b/content/bosses/tormented-demon/src/main/kotlin/org/rsmod/content/bosses/tormenteddemon/TormentedDemonCraftingScript.kt @@ -0,0 +1,121 @@ +package org.rsmod.content.bosses.tormenteddemon + +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.craftingLvl +import org.rsmod.api.player.stat.fletchingLvl +import org.rsmod.api.player.stat.smithingLvl +import org.rsmod.api.script.onOpHeldU +import org.rsmod.api.script.onOpLocCategoryU +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +class TormentedDemonCraftingScript : PluginScript() { + + override fun ScriptContext.startup() { + onOpHeldU("obj.bone_claw", "obj.bone_claw") { combineBurningClaws() } + onOpLocCategoryU("category.anvil", "obj.tormented_synapse") { craftSynapseAtAnvil() } + onOpHeldU("obj.tormented_synapse", "obj.unstrung_magic_longbow") { craftScorchingBow() } + } + + private fun ProtectedAccess.combineBurningClaws() { + if (invDel(inv, "obj.bone_claw", 2).success) { + invAdd(inv, "obj.bone_claws", 1) + mes("You bring the two claws together and combine them.") + } + } + + private suspend fun ProtectedAccess.craftSynapseAtAnvil() { + if (!hasHammer()) { + mesbox("You need a hammer to work metal with an anvil.") + return + } + when { + invTotal(inv, "obj.arclight") > 0 -> craftEmberlight() + invTotal(inv, "obj.iron_bar") > 0 && invTotal(inv, "obj.battlestaff") > 0 -> + craftPurgingStaff() + else -> + mesbox( + "You need Arclight, or an iron bar and a battlestaff, to work the " + + "tormented synapse into a weapon.", + ) + } + } + + private suspend fun ProtectedAccess.craftEmberlight() { + if (player.smithingLvl < 74) { + mesbox( + "You need a Smithing level of at least 74 to attach the tormented synapse " + + "to Arclight.", + ) + return + } + + mesbox("You set to work fusing the synapse into Arclight's blade...") + delay(3) + anim("seq.human_smithing") + soundSynth(3771) + delay(4) + + if (invDel(inv, "obj.tormented_synapse", 1).success && invDel(inv, "obj.arclight", 1).success) { + invAdd(inv, "obj.emberlight", 1) + statAdvance("stat.smithing", 730.0) + objbox("obj.emberlight", "The synapse fuses with the blade, and Emberlight is complete.") + } + } + + private suspend fun ProtectedAccess.craftPurgingStaff() { + if (player.smithingLvl < 55 || player.craftingLvl < 74) { + mesbox( + "You need a Smithing level of at least 55 and a Crafting level of at least " + + "74 to work the tormented synapse into a battlestaff.", + ) + return + } + + mesbox("You set to work fusing the synapse into the battlestaff...") + delay(3) + anim("seq.human_smithing") + soundSynth(3771) + delay(4) + + val consumed = + invDel(inv, "obj.tormented_synapse", 1).success && + invDel(inv, "obj.iron_bar", 1).success && + invDel(inv, "obj.battlestaff", 1).success + if (consumed) { + invAdd(inv, "obj.purging_staff", 1) + statAdvance("stat.crafting", 730.0) + statAdvance("stat.smithing", 13.0) + objbox("obj.purging_staff", "The synapse fuses with the staff, and it starts to purge.") + } + } + + private suspend fun ProtectedAccess.craftScorchingBow() { + if (player.fletchingLvl < 74) { + mesbox( + "You need a Fletching level of at least 74 to work the tormented synapse " + + "into a magic longbow.", + ) + return + } + + mesbox("You bind the synapse to the bow, and it begins to smoulder...") + delay(3) + anim("seq.stringing_magic_longbow") + soundSynth(3771) + delay(2) + + if (invDel(inv, "obj.tormented_synapse", 1).success && + invDel(inv, "obj.unstrung_magic_longbow", 1).success + ) { + invAdd(inv, "obj.scorching_bow", 1) + statAdvance("stat.fletching", 730.0) + objbox("obj.scorching_bow", "The synapse fuses with the bow, and it starts to smoulder.") + } + } + + private fun ProtectedAccess.hasHammer(): Boolean = + inv.contains("obj.hammer") || + inv.contains("obj.imcando_hammer") || + inv.contains("obj.imcando_hammer_offhand") +} diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/Npc.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/Npc.kt index a58e89076..f5ad17547 100644 --- a/engine/game/src/main/kotlin/org/rsmod/game/entity/Npc.kt +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/Npc.kt @@ -10,17 +10,15 @@ import dev.openrune.types.HuntModeType import dev.openrune.types.MoveRestrict import dev.openrune.types.NpcMode import dev.openrune.types.NpcServerType -import dev.openrune.types.SequenceServerType -import dev.openrune.types.aconverted.SpotanimType import dev.openrune.util.BlockWalk import org.rsmod.annotations.InternalApi +import org.rsmod.game.damage.DamageContributions import org.rsmod.game.entity.npc.NpcInfoProtocol import org.rsmod.game.entity.npc.NpcUid import org.rsmod.game.entity.npc.OpVisibility import org.rsmod.game.entity.player.PlayerUid import org.rsmod.game.entity.util.EntityFaceTarget import org.rsmod.game.entity.util.PathingEntityCommon -import org.rsmod.game.damage.DamageContributions import org.rsmod.game.headbar.Headbar import org.rsmod.game.hero.HeroPoints import org.rsmod.game.hit.Hitmark @@ -140,6 +138,7 @@ public class Npc( public val regenRate: Int = type.regenRate public var huntClock: Int = 0 + // `Obj` and `LocInfo` are lightweight, otherwise we would wrap them in `WeakReference`. public var huntObj: Obj? = null public var huntLoc: LocInfo? = null @@ -422,6 +421,14 @@ public class Npc( infoProtocol.toggleOps(OpVisibility.showAll()) } + public fun setHeadIcon(slot: Int, graphic: Int, index: Int) { + infoProtocol.setHeadIcon(slot, graphic, index) + } + + public fun clearHeadIcon(slot: Int) { + infoProtocol.setHeadIcon(slot, -1, -1) + } + public fun hideAllOps() { infoProtocol.toggleOps(OpVisibility.hideAll()) } @@ -508,6 +515,14 @@ public class Npc( infoProtocol.resetTransmog(originalType = id) } + public fun setBodyModel(model: Int) { + infoProtocol.setBodyModel(model) + } + + public fun resetBodyModel() { + infoProtocol.resetBodyModel() + } + public fun copyStats(from: NpcServerType) { copyBaseStats(from) copyCurrentStats(from) diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt index c8f91f3cf..2656dcefe 100644 --- a/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/Player.kt @@ -4,11 +4,11 @@ import dev.openrune.rscm.RSCM import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType import dev.openrune.types.BasType -import dev.or2.central.account.Rights -import dev.or2.central.account.TwoFactorAuthData -import dev.or2.central.account.TrustedDeviceData import dev.openrune.types.NpcServerType import dev.openrune.types.StatType +import dev.or2.central.account.Rights +import dev.or2.central.account.TrustedDeviceData +import dev.or2.central.account.TwoFactorAuthData import it.unimi.dsi.fastutil.ints.IntArrayList import it.unimi.dsi.fastutil.ints.IntArraySet import it.unimi.dsi.fastutil.ints.IntList @@ -25,12 +25,12 @@ import org.rsmod.game.client.Client import org.rsmod.game.client.ClientCycle import org.rsmod.game.client.NoopClient import org.rsmod.game.client.NoopClientCycle +import org.rsmod.game.damage.DamageContributions import org.rsmod.game.entity.player.Appearance import org.rsmod.game.entity.player.PlayerUid import org.rsmod.game.entity.player.PublicMessage import org.rsmod.game.entity.util.EntityFaceAngle import org.rsmod.game.entity.util.PathingEntityCommon -import org.rsmod.game.damage.DamageContributions import org.rsmod.game.headbar.Headbar import org.rsmod.game.hero.HeroPoints import org.rsmod.game.hit.Hitmark @@ -241,6 +241,7 @@ public class Player( public var loggingOut: Boolean = false public var pendingCloseClient: Boolean = false public var closeClient: Boolean = false + /** This flag should only be set when the game server is in the process of shutting down. */ public var pendingShutdown: Boolean = false diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/npc/NpcInfoProtocol.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/npc/NpcInfoProtocol.kt index f34eb1978..9b3ee6c4d 100644 --- a/engine/game/src/main/kotlin/org/rsmod/game/entity/npc/NpcInfoProtocol.kt +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/npc/NpcInfoProtocol.kt @@ -18,6 +18,10 @@ public interface NpcInfoProtocol { public fun resetTransmog(originalType: Int) + public fun setBodyModel(model: Int) + + public fun resetBodyModel() + public fun showHeadbar(headbar: Headbar) public fun showHitmark(hitmark: Hitmark) @@ -68,6 +72,10 @@ public data object NoopNpcInfo : NpcInfoProtocol { override fun resetTransmog(originalType: Int) {} + override fun setBodyModel(model: Int) {} + + override fun resetBodyModel() {} + override fun showHeadbar(headbar: Headbar) {} override fun showHitmark(hitmark: Hitmark) {} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fc11d6f44..dd19998f5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,7 @@ netty = "4.2.2.Final" okhttp = "5.0.0-alpha.16" otp-kotlin = "2.4.1" openrs2 = "0.1.0-SNAPSHOT" -rsprot = "1.0.0-ALPHA-20260804" +rsprot = "1.0.0-ALPHA-20260825" simmetrics = "4.1.1" shadow = "8.3.7" spotless = "7.0.4" diff --git a/or-cache/src/main/kotlin/dev/openrune/codec/osrs/impl/HuntCodec.kt b/or-cache/src/main/kotlin/dev/openrune/codec/osrs/impl/HuntCodec.kt index 5712a7664..8dffbe3c7 100644 --- a/or-cache/src/main/kotlin/dev/openrune/codec/osrs/impl/HuntCodec.kt +++ b/or-cache/src/main/kotlin/dev/openrune/codec/osrs/impl/HuntCodec.kt @@ -36,8 +36,8 @@ class HuntCodec(val custom: Map? = emptyMap()) : add(DefinitionOpcode(6, enumType(), HuntModeType::findNewMode)) add(DefinitionOpcode(7, enumType(), HuntModeType::nobodyNear)) - add(DefinitionOpcode(8, OpcodeType.SHORT, HuntModeType::checkNotCombat)) - add(DefinitionOpcode(9, OpcodeType.SHORT, HuntModeType::checkNotCombatSelf)) + add(DefinitionOpcode(8, OpcodeType.USHORT, HuntModeType::checkNotCombat)) + add(DefinitionOpcode(9, OpcodeType.USHORT, HuntModeType::checkNotCombatSelf)) add(DefinitionOpcode(10, OpcodeType.BOOLEAN, HuntModeType::checkAfk))