diff --git a/.data/gamevals/projanim.rscm b/.data/gamevals/projanim.rscm index 76fbf427b..53cb3dec6 100644 --- a/.data/gamevals/projanim.rscm +++ b/.data/gamevals/projanim.rscm @@ -16,3 +16,4 @@ bind=14 tumekens_shadow=15 dragonfire=16 godwars_bandos_ranged=17 +toxic_blowpipe_special=18 diff --git a/.data/raw-cache/server/items.toml b/.data/raw-cache/server/items.toml index 4a3bb87a9..aa0232cb4 100644 --- a/.data/raw-cache/server/items.toml +++ b/.data/raw-cache/server/items.toml @@ -16334,6 +16334,31 @@ tradeable = false "param.bas_walk_b"="seq.human_halberdwalk_b" +[[item]] +id = "obj.rosewood_blowpipe" +inherit = "obj.rosewood_blowpipe" +weaponCategory="Thrown" +tradeable = false +[item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.proj_type"="projanim.thrown" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" +"param.attackrate"=3 +"param.attack_range"=5 +"param.attack_anim_stance1"="seq.rosewood_blowpipe_attack" +"param.attack_sound_stance1"=2696 +"param.attack_anim_stance2"="seq.rosewood_blowpipe_attack" +"param.attack_sound_stance2"=2696 +"param.attack_anim_stance4"="seq.rosewood_blowpipe_attack" +"param.attack_sound_stance4"=2696 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_spear_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" + + [[item]] id = "obj.ntk_jewelled_sceptre_8" inherit = "obj.ntk_jewelled_sceptre_8" @@ -18678,6 +18703,29 @@ weaponCategory="StabSword" "param.defend_anim"="seq.human_sword_def" +[[item]] +# Was undefined entirely (no weaponCategory, no attack anims) before this fix - wielding it had no +# combat style options and no attack animation. Wiki: "the attack animation for Belle's folly is +# identical to the Ghrazi rapier" - same seqs/sounds/category as that entry above, just this item's +# id. `belles_folly_tarnished` (31245) deliberately has no Wield option in the cache at all (needs +# repairing first per its own examine text/quest lore), so it doesn't need this treatment. +id = "obj.belles_folly" +inherit = "obj.belles_folly" +weaponCategory="StabSword" +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.ghrazi_rapier_attack" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.ghrazi_rapier_attack" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_sword_slash" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.ghrazi_rapier_attack" +"param.attack_sound_stance4"=2549 +"param.bas_readyanim"="seq.human_ds_ready" +"param.defend_anim"="seq.human_sword_def" + + [[item]] id = "obj.scythe_of_vitur" inherit = "obj.scythe_of_vitur" @@ -18974,6 +19022,9 @@ weaponCategory="Bow" "param.attack_anim_stance4"="seq.human_bow" "param.attack_sound_stance4"=2693 "param.defend_anim"="seq.human_unarmedblock" +# Needed for ObjChargeManager.addCharges to swap this into its charged variant when ether is +# added (Configure-Charges). +"param.charged_variant"="obj.wild_cave_bow_charged" [[item]] @@ -18994,6 +19045,10 @@ tradeable = false "param.attack_anim_stance4"="seq.human_bow" "param.attack_sound_stance4"=2693 "param.defend_anim"="seq.human_unarmedblock" +# ObjChargeManager.reduceWornCharges (used by both the normal attack and, once implemented, a +# special attack) throws IllegalStateException without it, since it needs to know what to swap +# the item into once it runs out of ether. +"param.uncharged_variant"="obj.wild_cave_bow_uncharged" [[item]] @@ -19123,6 +19178,10 @@ weaponCategory="Thrown" "param.proj_travel"="spotanim.morrigans_javelin_travel" "param.proj_launch"="spotanim.morrigans_javelin_launch" "param.equipment_sound"=2247 +# Missing entirely before this fix - fell back to attackRange=1 (melee-only) since +# `params.attackrange` had no value on this item. Both cache variants (br_morrigans_javelin, +# morrigans_javelin_bh) already had this set to 5; the base item was simply never given it. +"param.attack_range"=5 "param.attack_anim_stance1"="seq.human_stake2" "param.attack_sound_stance1"=2699 "param.attack_anim_stance2"="seq.human_stake2" @@ -21219,6 +21278,30 @@ tradeable = false "param.bas_walk_b"="seq.ivandis_flail_walk_b" +[[item]] +id = "obj.hallowed_flail" +inherit = "obj.hallowed_flail" +weaponCategory="Staff" +tradeable = false +[item.params] +"param.bas_walk_l"="seq.ivandis_flail_walk_l" +"param.bas_walk_r"="seq.ivandis_flail_walk_r" +"param.bas_running"="seq.ivandis_flail_run" +"param.equipment_sound"=2246 +"param.attackrate"=5 +"param.attack_anim_stance1"="seq.ivandis_flail_attack" +"param.attack_sound_stance1"=1713 +"param.attack_anim_stance2"="seq.ivandis_flail_attack" +"param.attack_sound_stance2"=1713 +"param.attack_anim_stance4"="seq.ivandis_flail_attack" +"param.attack_sound_stance4"=1713 +"param.bas_readyanim"="seq.ivandis_flail_ready" +"param.bas_turnonspot"="seq.ivandis_flail_turnonspot" +"param.defend_anim"="seq.ivandis_flail_defend" +"param.bas_walk_f"="seq.ivandis_flail_walk_f" +"param.bas_walk_b"="seq.ivandis_flail_walk_b" + + [[item]] id = "obj.trailblazer_cane" inherit = "obj.trailblazer_cane" @@ -22956,6 +23039,9 @@ weaponCategory="Bow" "param.attack_anim_stance4"="seq.human_bow" "param.attack_sound_stance4"=2693 "param.defend_anim"="seq.human_unarmedblock" +# Needed for ObjChargeManager.addCharges to swap this into its charged variant when ether is +# added (Configure-Charges). +"param.charged_variant"="obj.wild_cave_webweaver_charged" [[item]] @@ -22976,6 +23062,10 @@ tradeable = false "param.attack_anim_stance4"="seq.human_bow" "param.attack_sound_stance4"=2693 "param.defend_anim"="seq.human_unarmedblock" +# Missing entirely before this fix - ObjChargeManager.reduceWornCharges (used by both the special +# attack and the normal-attack fix in WebweaverBowWeapons.kt) throws IllegalStateException without +# it, since it needs to know what to swap the item into once it runs out of ether. +"param.uncharged_variant"="obj.wild_cave_webweaver_uncharged" [[item]] @@ -23379,6 +23469,31 @@ weaponCategory="Axe" [item.params] "param.bas_walk_l"="seq.ancient_axe_walk" "param.bas_walk_r"="seq.ancient_axe_walk" +"param.bas_running"="seq.ancient_axe_run" +"param.equipment_sound"=2232 +"param.attackrate"=5 +"param.attack_anim_stance1"="seq.ancient_axe_crush" +"param.attack_sound_stance1"=1321 +"param.attack_anim_stance2"="seq.ancient_axe_crush" +"param.attack_sound_stance2"=1321 +"param.attack_anim_stance3"="seq.ancient_axe_slash" +"param.attack_sound_stance3"=1316 +"param.attack_anim_stance4"="seq.ancient_axe_crush" +"param.attack_sound_stance4"=1321 +"param.bas_readyanim"="seq.ancient_axe_idle" +"param.defend_anim"="seq.human_unarmedblock" +"param.bas_walk_f"="seq.ancient_axe_walk" +"param.bas_walk_b"="seq.ancient_axe_walk" + + +[[item]] +id = "obj.soulreaper_axe_orn" +inherit = "obj.soulreaper_axe_orn" +weaponCategory="Axe" +[item.params] +"param.bas_walk_l"="seq.ancient_axe_walk" +"param.bas_walk_r"="seq.ancient_axe_walk" +"param.bas_running"="seq.ancient_axe_run" "param.equipment_sound"=2232 "param.attackrate"=5 "param.attack_anim_stance1"="seq.ancient_axe_crush" @@ -23524,6 +23639,7 @@ inherit = "obj.eclipse_atlatl" weaponCategory="Bow" [item.params] "param.bas_walk_l"="seq.human_halberdwalk_l" +"param.required_ammo"="category.atlatl_dart" "param.proj_type"="projanim.arrow" "param.bas_walk_r"="seq.human_halberdwalk_r" "param.bas_running"="seq.human_halberdrunning" @@ -23615,6 +23731,7 @@ weaponCategory="Bow" tradeable = false [item.params] "param.bas_walk_l"="seq.human_halberdwalk_l" +"param.required_ammo"="category.atlatl_dart" "param.proj_type"="projanim.arrow" "param.bas_walk_r"="seq.human_halberdwalk_r" "param.bas_running"="seq.human_halberdrunning" @@ -34999,6 +35116,15 @@ weaponCategory="StabSword" tradeable = false [item.params] "param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_ddagger_lunge" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_ddagger_lunge" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_ddagger_hack" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_ddagger_lunge" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_ddagger_block" [[item]] @@ -35008,6 +35134,15 @@ weaponCategory="StabSword" tradeable = false [item.params] "param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_ddagger_lunge" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_ddagger_lunge" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_ddagger_hack" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_ddagger_lunge" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_ddagger_block" [[item]] @@ -35017,6 +35152,15 @@ weaponCategory="StabSword" tradeable = false [item.params] "param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_ddagger_lunge" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_ddagger_lunge" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_ddagger_hack" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_ddagger_lunge" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_ddagger_block" [[item]] @@ -35026,6 +35170,15 @@ weaponCategory="StabSword" tradeable = false [item.params] "param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_ddagger_lunge" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_ddagger_lunge" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_ddagger_hack" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_ddagger_lunge" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_ddagger_block" [[item]] @@ -35035,6 +35188,15 @@ weaponCategory="Spiked" tradeable = false [item.params] "param.equipment_sound"=2246 +"param.attack_anim_stance1"="seq.human_blunt_pound" +"param.attack_sound_stance1"=2508 +"param.attack_anim_stance2"="seq.human_blunt_pound" +"param.attack_sound_stance2"=2508 +"param.attack_anim_stance3"="seq.human_blunt_spike" +"param.attack_sound_stance3"=2509 +"param.attack_anim_stance4"="seq.human_blunt_pound" +"param.attack_sound_stance4"=2508 +"param.defend_anim"="seq.human_blunt_block" [[item]] @@ -35044,6 +35206,15 @@ weaponCategory="StabSword" tradeable = false [item.params] "param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_stab" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_sword_stab" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_sword_slash" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_sword_stab" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_sword_def" [[item]] @@ -35053,6 +35224,14 @@ weaponCategory="SlashSword" tradeable = false [item.params] "param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 [[item]] @@ -35062,6 +35241,16 @@ weaponCategory="SlashSword" tradeable = false [item.params] "param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 +"param.bas_readyanim"="seq.human_ds_ready" +"param.defend_anim"="seq.human_sword_def" "param.attackrate"=5 @@ -35072,6 +35261,13 @@ weaponCategory="Blunt" tradeable = false [item.params] "param.equipment_sound"=2233 +"param.attack_anim_stance1"="seq.human_blunt_pound" +"param.attack_sound_stance1"=2567 +"param.attack_anim_stance2"="seq.human_blunt_pound" +"param.attack_sound_stance2"=2567 +"param.attack_anim_stance4"="seq.human_blunt_pound" +"param.attack_sound_stance4"=2567 +"param.defend_anim"="seq.human_blunt_block" "param.attackrate"=6 @@ -35082,6 +35278,15 @@ weaponCategory="Axe" tradeable = false [item.params] "param.equipment_sound"=2232 +"param.attack_anim_stance1"="seq.human_axe_hack" +"param.attack_sound_stance1"=2498 +"param.attack_anim_stance2"="seq.human_axe_hack" +"param.attack_sound_stance2"=2498 +"param.attack_anim_stance3"="seq.human_blunt_pound" +"param.attack_sound_stance3"=2497 +"param.attack_anim_stance4"="seq.human_axe_hack" +"param.attack_sound_stance4"=2498 +"param.defend_anim"="seq.human_axe_block" "param.attackrate"=6 @@ -35091,7 +35296,23 @@ inherit = "obj.bh_dragon_spear_corrupted" weaponCategory="Spear" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" "param.equipment_sound"=2247 +"param.attack_anim_stance1"="seq.human_dspear_stab" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_dspear_slash" +"param.attack_sound_stance2"=2556 +"param.attack_anim_stance3"="seq.human_dspear_lunge" +"param.attack_sound_stance3"=2555 +"param.attack_anim_stance4"="seq.human_dspear_stab" +"param.attack_sound_stance4"=2562 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_dspear_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] @@ -35100,7 +35321,23 @@ inherit = "obj.bh_dragon_spear_p_corrupted" weaponCategory="Spear" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" "param.equipment_sound"=2247 +"param.attack_anim_stance1"="seq.human_dspear_stab" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_dspear_slash" +"param.attack_sound_stance2"=2556 +"param.attack_anim_stance3"="seq.human_dspear_lunge" +"param.attack_sound_stance3"=2555 +"param.attack_anim_stance4"="seq.human_dspear_stab" +"param.attack_sound_stance4"=2562 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_dspear_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] @@ -35109,7 +35346,23 @@ inherit = "obj.bh_dragon_spear_p+_corrupted" weaponCategory="Spear" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" "param.equipment_sound"=2247 +"param.attack_anim_stance1"="seq.human_dspear_stab" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_dspear_slash" +"param.attack_sound_stance2"=2556 +"param.attack_anim_stance3"="seq.human_dspear_lunge" +"param.attack_sound_stance3"=2555 +"param.attack_anim_stance4"="seq.human_dspear_stab" +"param.attack_sound_stance4"=2562 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_dspear_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] @@ -35118,7 +35371,23 @@ inherit = "obj.bh_dragon_spear_p++_corrupted" weaponCategory="Spear" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" "param.equipment_sound"=2247 +"param.attack_anim_stance1"="seq.human_dspear_stab" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_dspear_slash" +"param.attack_sound_stance2"=2556 +"param.attack_anim_stance3"="seq.human_dspear_lunge" +"param.attack_sound_stance3"=2555 +"param.attack_anim_stance4"="seq.human_dspear_stab" +"param.attack_sound_stance4"=2562 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_dspear_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] @@ -35127,9 +35396,23 @@ inherit = "obj.bh_dragon_halberd_corrupted" weaponCategory="Polearm" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" "param.equipment_sound"=2247 -"param.attack_range"=2 "param.attackrate"=7 +"param.attack_range"=2 +"param.attack_anim_stance1"="seq.human_spear_spike" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_scythe_sweep" +"param.attack_sound_stance2"=2524 +"param.attack_anim_stance4"="seq.human_spear_spike" +"param.attack_sound_stance4"=2562 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_spear_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] @@ -35138,8 +35421,23 @@ inherit = "obj.bh_dragon_2h_sword_corrupted" weaponCategory="TwoHandedSword" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_dh_weapon_walk" +"param.bas_walk_r"="seq.human_dh_weapon_walk" +"param.bas_running"="seq.human_dh_weapon_run" "param.equipment_sound"=2248 "param.attackrate"=7 +"param.attack_anim_stance1"="seq.human_dhsword_slash" +"param.attack_sound_stance1"=2503 +"param.attack_anim_stance2"="seq.human_dhsword_slash" +"param.attack_sound_stance2"=2503 +"param.attack_anim_stance3"="seq.human_dhsword_chop" +"param.attack_sound_stance3"=2502 +"param.attack_anim_stance4"="seq.human_dhsword_slash" +"param.attack_sound_stance4"=2503 +"param.bas_readyanim"="seq.human_dh_weapon_ready" +"param.defend_anim"="seq.human_dhsword_block" +"param.bas_walk_f"="seq.human_dh_weapon_walk" +"param.bas_walk_b"="seq.human_dh_weapon_walk" [[item]] @@ -35149,9 +35447,20 @@ weaponCategory="Crossbow" tradeable = false [item.params] "param.proj_type"="projanim.bolt" +"param.bas_running"="seq.xbows_human_run" "param.equipment_sound"=2244 -"param.attack_range"=7 "param.attackrate"=6 +"param.attack_range"=7 +"param.attack_anim_stance1"="seq.xbows_human_fire_and_reload_pvn" +"param.attack_sound_stance1"=2695 +"param.attack_anim_stance2"="seq.xbows_human_fire_and_reload_pvn" +"param.attack_sound_stance2"=2695 +"param.attack_anim_stance4"="seq.xbows_human_fire_and_reload_pvn" +"param.attack_sound_stance4"=2695 +"param.bas_readyanim"="seq.xbows_human_ready" +"param.defend_anim"="seq.human_unarmedblock" +"param.bas_walk_f"="seq.xbows_human_walk_f" +"param.bas_walk_b"="seq.xbows_human_walk_b" [[item]] @@ -35408,6 +35717,7 @@ id = "obj.atlatl_dart" inherit = "obj.atlatl_dart" [item.params] "param.equipment_sound"=2244 +"param.proj_travel"="spotanim.vfx_atlatl_projectile_01" [[item]] @@ -47021,7 +47331,6 @@ tradeable = false [[item]] id = "obj.skillcape_max_dizanas" inherit = "obj.skillcape_max_dizanas" -contentGroup = "content.max_cape" tradeable = false [item.params] "param.destroy_note_desc"="You can get another from The Colosseum." @@ -47030,7 +47339,6 @@ tradeable = false [[item]] id = "obj.skillcape_max_dizanas_trouver" inherit = "obj.skillcape_max_dizanas_trouver" -contentGroup = "content.max_cape" tradeable = false [item.params] "param.destroy_note_desc"="You can get another from The Colosseum." @@ -49600,7 +49908,6 @@ tradeable = false [[item]] id = "obj.skillcape_max_anma" inherit = "obj.skillcape_max_anma" -contentGroup = "content.max_cape" tradeable = false [item.params] "param.ammo_recovery_rate"=72 @@ -49662,6 +49969,16 @@ inherit = "obj.trailblazer_reloaded_axe" weaponCategory="Axe" tradeable = false [item.params] +"param.equipment_sound"=2229 +"param.attack_anim_stance1"="seq.human_axe_hack" +"param.attack_sound_stance1"=2498 +"param.attack_anim_stance2"="seq.human_axe_hack" +"param.attack_sound_stance2"=2498 +"param.attack_anim_stance3"="seq.human_blunt_pound" +"param.attack_sound_stance3"=2497 +"param.attack_anim_stance4"="seq.human_axe_hack" +"param.attack_sound_stance4"=2498 +"param.defend_anim"="seq.human_axe_block" "param.attackrate"=5 @@ -49904,6 +50221,16 @@ inherit = "obj.trailblazer_reloaded_axe_empty" weaponCategory="Axe" tradeable = false [item.params] +"param.equipment_sound"=2229 +"param.attack_anim_stance1"="seq.human_axe_hack" +"param.attack_sound_stance1"=2498 +"param.attack_anim_stance2"="seq.human_axe_hack" +"param.attack_sound_stance2"=2498 +"param.attack_anim_stance3"="seq.human_blunt_pound" +"param.attack_sound_stance3"=2497 +"param.attack_anim_stance4"="seq.human_axe_hack" +"param.attack_sound_stance4"=2498 +"param.defend_anim"="seq.human_axe_block" "param.attackrate"=5 @@ -49964,7 +50291,22 @@ id = "obj.deadman_blighted_volatile_staff" inherit = "obj.deadman_blighted_volatile_staff" weaponCategory="Staff" [item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" +"param.equipment_sound"=2247 "param.attackrate"=5 +"param.attack_anim_stance1"="seq.human_nightmare_staff_crush" +"param.attack_sound_stance1"=2555 +"param.attack_anim_stance2"="seq.human_nightmare_staff_crush" +"param.attack_sound_stance2"=2555 +"param.attack_anim_stance4"="seq.human_nightmare_staff_crush" +"param.attack_sound_stance4"=2555 +"param.bas_readyanim"="seq.human_nightmare_staff_ready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_stafforb_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] @@ -50022,12 +50364,17 @@ tradeable = false id = "obj.deadman_blighted_voidwaker" inherit = "obj.deadman_blighted_voidwaker" weaponCategory="SlashSword" - - -[[item]] -id = "obj.bone_claws" -inherit = "obj.bone_claws" -weaponCategory="Claw" +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 +"param.defend_anim"="seq.human_sword_def" [[item]] @@ -50141,6 +50488,16 @@ inherit = "obj.trailblazer_reloaded_pickaxe" weaponCategory="Pickaxe" tradeable = false [item.params] +"param.equipment_sound"=2229 +"param.attack_anim_stance1"="seq.human_blunt_pound" +"param.attack_sound_stance1"=2498 +"param.attack_anim_stance2"="seq.human_blunt_pound" +"param.attack_sound_stance2"=2498 +"param.attack_anim_stance3"="seq.human_blunt_spike" +"param.attack_sound_stance3"=2497 +"param.attack_anim_stance4"="seq.human_blunt_pound" +"param.attack_sound_stance4"=2498 +"param.defend_anim"="seq.human_axe_block" "param.attackrate"=5 @@ -50275,6 +50632,17 @@ id = "obj.br_dual_macuahuitl" inherit = "obj.br_dual_macuahuitl" weaponCategory="Spiked" tradeable = false +[item.params] +"param.equipment_sound"=2233 +"param.attack_anim_stance1"="seq.pmoon_macuahuitl_crush" +"param.attack_sound_stance1"=7930 +"param.attack_anim_stance2"="seq.pmoon_macuahuitl_crush" +"param.attack_sound_stance2"=7930 +"param.attack_anim_stance3"="seq.pmoon_macuahuitl_crush" +"param.attack_sound_stance3"=7930 +"param.attack_anim_stance4"="seq.pmoon_macuahuitl_crush" +"param.attack_sound_stance4"=7930 +"param.defend_anim"="seq.human_unarmedblock" [[item]] @@ -50294,7 +50662,22 @@ inherit = "obj.deadman_nightmare_staff_volatile" weaponCategory="Staff" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" +"param.equipment_sound"=2247 "param.attackrate"=5 +"param.attack_anim_stance1"="seq.human_nightmare_staff_crush" +"param.attack_sound_stance1"=2555 +"param.attack_anim_stance2"="seq.human_nightmare_staff_crush" +"param.attack_sound_stance2"=2555 +"param.attack_anim_stance4"="seq.human_nightmare_staff_crush" +"param.attack_sound_stance4"=2555 +"param.bas_readyanim"="seq.human_nightmare_staff_ready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_stafforb_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] @@ -50324,6 +50707,16 @@ inherit = "obj.trailblazer_reloaded_pickaxe_no_infernal" weaponCategory="Pickaxe" tradeable = false [item.params] +"param.equipment_sound"=2229 +"param.attack_anim_stance1"="seq.human_blunt_pound" +"param.attack_sound_stance1"=2498 +"param.attack_anim_stance2"="seq.human_blunt_pound" +"param.attack_sound_stance2"=2498 +"param.attack_anim_stance3"="seq.human_blunt_spike" +"param.attack_sound_stance3"=2497 +"param.attack_anim_stance4"="seq.human_blunt_pound" +"param.attack_sound_stance4"=2498 +"param.defend_anim"="seq.human_axe_block" "param.attackrate"=5 @@ -50585,6 +50978,17 @@ id = "obj.deadman_voidwaker" inherit = "obj.deadman_voidwaker" weaponCategory="SlashSword" tradeable = false +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 +"param.defend_anim"="seq.human_sword_def" [[item]] @@ -50599,7 +51003,6 @@ tradeable = false [[item]] id = "obj.skillcape_max_assembler_masori_trouver" inherit = "obj.skillcape_max_assembler_masori_trouver" -contentGroup = "content.max_cape" tradeable = false [item.params] "param.ammo_recovery_rate"=80 @@ -50709,6 +51112,198 @@ id = "obj.thunder_khopesh" inherit = "obj.thunder_khopesh" weaponCategory="SlashSword" tradeable = false +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 + + +[[item]] +id = "obj.deadman_thunder_khopesh" +inherit = "obj.deadman_thunder_khopesh" +weaponCategory="SlashSword" +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 + + +[[item]] +id = "obj.crimson_kisten" +inherit = "obj.crimson_kisten" +weaponCategory="Spiked" +[item.params] +# These attack_anim_stance* params are NOT what actually controls this weapon's swing animation - +# CrimsonKistenWeapons.kt hardcodes it directly in code instead (found only after the diagnostic +# test above showed these overrides have zero visible effect). Kept here matching the item's real +# named sequences anyway, for accuracy/in case anything else ever reads them. +"param.attack_anim_stance1"="seq.human_weapons_crimson_kisten_attack" +"param.attack_sound_stance1"=401 +"param.attack_anim_stance2"="seq.human_weapons_crimson_kisten_attack" +"param.attack_sound_stance2"=401 +"param.attack_anim_stance3"="seq.human_weapons_crimson_kisten_attack_alt" +"param.attack_sound_stance3"=401 +"param.attack_anim_stance4"="seq.human_weapons_crimson_kisten_attack" +"param.attack_sound_stance4"=401 +# Straight from torka's original notes (the actual weapon's own real animation ids, not a +# borrowed reference weapon - every guess before this, Verac's flail/Blisterwood flail/Rune 2h +# sword/Soulreaper axe, was wrong): Ready=14251, Walk=14252, Run=14254. Stab (14253) and +# Defend (14257) above already independently matched these same notes, confirming this source. +"param.bas_readyanim"=14251 +"param.bas_walk_f"=14252 +"param.bas_walk_b"=14252 +"param.bas_walk_l"=14252 +"param.bas_walk_r"=14252 +"param.bas_running"=14254 + + +[[item]] +id = "obj.fang_of_the_hound" +inherit = "obj.fang_of_the_hound" +weaponCategory="StabSword" +[item.params] +"param.attack_anim_stance1"="seq.human_karambit_attack" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_karambit_attack" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_karambit_attack" +"param.attack_sound_stance3"=2549 +"param.attack_anim_stance4"="seq.human_karambit_attack" +"param.attack_sound_stance4"=2549 +# StabSword needs its idle/defend anim overridden per-item (confirmed via Ghrazi rapier, a real +# StabSword weapon - it has no turn/walk/run override, only these two, so that's all copied here). +"param.bas_readyanim"=809 +"param.defend_anim"=388 + + +[[item]] +id = "obj.sunspear" +inherit = "obj.sunspear" +weaponCategory="Spear" +[item.params] +"param.attack_anim_stance1"="seq.human_spear_spike" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_scythe_sweep" +"param.attack_sound_stance2"=2556 +"param.attack_anim_stance3"="seq.human_spear_lunge" +"param.attack_sound_stance3"=2555 +"param.attack_anim_stance4"="seq.human_spear_spike" +"param.attack_sound_stance4"=2562 +# First attempt reused Dragon spear's set (813 etc) - live feedback said it should match +# Zamorakian spear specifically instead, and checking the cache confirms Zamorakian spear (id +# 11824) uses a completely different set (1713 etc) from Dragon spear/Guthan's warspear/ +# Zamorakian hasta (all 813) - a real, distinct "two-handed-looking" Spear-category grip. Switched +# to Zamorakian spear's exact values. +"param.bas_readyanim"=1713 +"param.bas_turnonspot"=1702 +"param.bas_walk_f"=1703 +"param.bas_walk_b"=1704 +"param.bas_walk_l"=1706 +"param.bas_walk_r"=1705 +"param.bas_running"=1707 +"param.defend_anim"=1709 + + +[[item]] +id = "obj.echo_godsword" +inherit = "obj.echo_godsword" +# GodSword (not the generic TwoHandedSword) - the more accurate category for the combat tab, but +# NOT what actually drives the idle "holding" pose - see bas_readyanim etc below for that. +weaponCategory="GodSword" +tradeable = false +[item.params] +"param.equipment_sound"=2248 +"param.attackrate"=6 +"param.attack_anim_stance1"="seq.human_dhsword_slash" +"param.attack_sound_stance1"=2503 +"param.attack_anim_stance2"="seq.human_dhsword_slash" +"param.attack_sound_stance2"=2503 +"param.attack_anim_stance3"="seq.human_dhsword_chop" +"param.attack_sound_stance3"=2502 +"param.attack_anim_stance4"="seq.human_dhsword_slash" +# The real Bandos godsword's own idle/turn/walk/run/defend animation set (obj id 11804) - these +# raw ids (7043-7056) have no named RSCM alias in this cache, same situation as the attack-stance +# ids handled in code via `DogswordWeapons.kt`. Unlike those, these are read by the client directly +# from the item definition (not something server code can override at attack-time), so they have +# to go in the cache override as raw ints rather than named `seq.` strings. +"param.bas_readyanim"=7053 +"param.bas_turnonspot"=7044 +"param.bas_walk_f"=7052 +"param.bas_walk_b"=7052 +"param.bas_walk_l"=7048 +"param.bas_walk_r"=7047 +"param.bas_running"=7043 +"param.defend_anim"=7056 +"param.attack_sound_stance4"=2503 + + +[[item]] +id = "obj.bone_claws" +inherit = "obj.bone_claws" +weaponCategory="Claw" +[item.params] +"param.equipment_sound"=2238 +"param.attack_anim_stance1"="seq.human_axe_chop" +"param.attack_sound_stance1"=4139 +"param.attack_anim_stance2"="seq.human_axe_chop" +"param.attack_sound_stance2"=4139 +"param.attack_anim_stance3"="seq.d_claws_punch" +"param.attack_sound_stance3"=4139 +"param.attack_anim_stance4"="seq.human_axe_chop" +"param.attack_sound_stance4"=4139 + + +[[item]] +id = "obj.arkan_blade" +inherit = "obj.arkan_blade" +weaponCategory="SlashSword" +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 + + +[[item]] +id = "obj.deadman_dogsword" +inherit = "obj.deadman_dogsword" +# See obj.echo_godsword above - GodSword category plus the bas_* idle/turn/walk/run/defend set. +weaponCategory="GodSword" +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_dhsword_slash" +"param.attack_sound_stance1"=2503 +"param.attack_anim_stance2"="seq.human_dhsword_slash" +"param.attack_sound_stance2"=2503 +"param.attack_anim_stance3"="seq.human_dhsword_chop" +"param.attack_sound_stance3"=2502 +"param.attack_anim_stance4"="seq.human_dhsword_slash" +"param.bas_readyanim"=7053 +"param.bas_turnonspot"=7044 +"param.bas_walk_f"=7052 +"param.bas_walk_b"=7052 +"param.bas_walk_l"=7048 +"param.bas_walk_r"=7047 +"param.bas_running"=7043 +"param.defend_anim"=7056 +"param.attack_sound_stance4"=2503 [[item]] @@ -50738,7 +51333,16 @@ inherit = "obj.dinhs_bulwark_ornament" weaponCategory="Bulwark" tradeable = false [item.params] +"param.bas_walk_l"="seq.human_dinhs_bulwark_walk" +"param.bas_walk_r"="seq.human_dinhs_bulwark_walk" +"param.bas_running"="seq.human_dinhs_bulwark_run" +"param.attack_anim_stance1"="seq.human_dinhs_bulwark_bash" +"param.attack_sound_stance1"=3454 +"param.bas_readyanim"="seq.human_dinhs_bulwark_ready" +"param.defend_anim"="seq.human_dinhs_bulwark_block" +"param.bas_walk_f"="seq.human_dinhs_bulwark_walk" "param.attackrate"=5 +"param.bas_walk_b"="seq.human_dinhs_bulwark_walk" [[item]] @@ -50769,7 +51373,24 @@ inherit = "obj.deadman_ags" weaponCategory="TwoHandedSword" tradeable = false [item.params] +"param.bas_walk_l"="seq.dh_sword_update_walk_left" +"param.bas_walk_r"="seq.dh_sword_update_walk_right" +"param.bas_running"="seq.dh_sword_update_run" +"param.equipment_sound"=2248 "param.attackrate"=6 +"param.attack_anim_stance1"="seq.dh_sword_update_slash" +"param.attack_sound_stance1"=3847 +"param.attack_anim_stance2"="seq.dh_sword_update_slash" +"param.attack_sound_stance2"=3847 +"param.attack_anim_stance3"="seq.dh_sword_update_smash" +"param.attack_sound_stance3"=3846 +"param.attack_anim_stance4"="seq.dh_sword_update_block" +"param.attack_sound_stance4"=3847 +"param.bas_readyanim"="seq.dh_sword_update_ready" +"param.bas_turnonspot"="seq.dh_sword_update_turnonspot" +"param.defend_anim"="seq.dh_sword_update_defend" +"param.bas_walk_f"="seq.dh_sword_update_walk" +"param.bas_walk_b"="seq.dh_sword_update_walk" [[item]] @@ -50784,12 +51405,21 @@ id = "obj.bh_dragon_claws_corrupted" inherit = "obj.bh_dragon_claws_corrupted" weaponCategory="Claw" tradeable = false +[item.params] +"param.attack_anim_stance1"="seq.human_axe_chop" +"param.attack_sound_stance1"=4139 +"param.attack_anim_stance2"="seq.human_axe_chop" +"param.attack_sound_stance2"=4139 +"param.attack_anim_stance3"="seq.d_claws_punch" +"param.attack_sound_stance3"=4139 +"param.attack_anim_stance4"="seq.human_axe_chop" +"param.attack_sound_stance4"=4139 +"param.defend_anim"="seq.human_unarmedblock" [[item]] id = "obj.skillcape_max_assembler_trouver" inherit = "obj.skillcape_max_assembler_trouver" -contentGroup = "content.max_cape" tradeable = false [item.params] "param.ammo_recovery_rate"=80 @@ -51047,7 +51677,24 @@ id = "obj.deadman_blighted_ags" inherit = "obj.deadman_blighted_ags" weaponCategory="TwoHandedSword" [item.params] +"param.bas_walk_l"="seq.dh_sword_update_walk_left" +"param.bas_walk_r"="seq.dh_sword_update_walk_right" +"param.bas_running"="seq.dh_sword_update_run" +"param.equipment_sound"=2248 "param.attackrate"=6 +"param.attack_anim_stance1"="seq.dh_sword_update_slash" +"param.attack_sound_stance1"=3847 +"param.attack_anim_stance2"="seq.dh_sword_update_slash" +"param.attack_sound_stance2"=3847 +"param.attack_anim_stance3"="seq.dh_sword_update_smash" +"param.attack_sound_stance3"=3846 +"param.attack_anim_stance4"="seq.dh_sword_update_block" +"param.attack_sound_stance4"=3847 +"param.bas_readyanim"="seq.dh_sword_update_ready" +"param.bas_turnonspot"="seq.dh_sword_update_turnonspot" +"param.defend_anim"="seq.dh_sword_update_defend" +"param.bas_walk_f"="seq.dh_sword_update_walk" +"param.bas_walk_b"="seq.dh_sword_update_walk" [[item]] @@ -51129,7 +51776,6 @@ tradeable = false [[item]] id = "obj.skillcape_max_assembler" inherit = "obj.skillcape_max_assembler" -contentGroup = "content.max_cape" tradeable = false [item.params] "param.ammo_recovery_rate"=80 @@ -51275,6 +51921,15 @@ inherit = "obj.trailblazer_reloaded_harpoon_empty" weaponCategory="StabSword" tradeable = false [item.params] +"param.attack_anim_stance1"="seq.human_sword_stab" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_sword_stab" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_sword_slash" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_sword_stab" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_sword_def" "param.attackrate"=5 @@ -51302,15 +51957,6 @@ dummyitem = 2 tradeable = false -[[item]] -id = "obj.echo_godsword" -inherit = "obj.echo_godsword" -weaponCategory="TwoHandedSword" -tradeable = false -[item.params] -"param.attackrate"=6 - - [[item]] id = "obj.barronite_mace_broken" inherit = "obj.barronite_mace_broken" @@ -51517,8 +52163,18 @@ weaponCategory="Bow" tradeable = false [item.params] "param.proj_type"="projanim.arrow" -"param.attack_range"=10 +"param.equipment_sound"=2244 "param.attackrate"=9 +"param.statreq1_skill"="stat.ranged" +"param.attack_range"=10 +"param.attack_anim_stance1"="seq.human_bow" +"param.statreq1_level"=60 +"param.attack_sound_stance1"=3731 +"param.attack_anim_stance2"="seq.human_bow" +"param.attack_sound_stance2"=3731 +"param.attack_anim_stance4"="seq.human_bow" +"param.attack_sound_stance4"=3731 +"param.defend_anim"="seq.human_unarmedblock" [[item]] @@ -51575,6 +52231,16 @@ inherit = "obj.trailblazer_reloaded_axe_no_infernal" weaponCategory="Axe" tradeable = false [item.params] +"param.equipment_sound"=2229 +"param.attack_anim_stance1"="seq.human_axe_hack" +"param.attack_sound_stance1"=2498 +"param.attack_anim_stance2"="seq.human_axe_hack" +"param.attack_sound_stance2"=2498 +"param.attack_anim_stance3"="seq.human_blunt_pound" +"param.attack_sound_stance3"=2497 +"param.attack_anim_stance4"="seq.human_axe_hack" +"param.attack_sound_stance4"=2498 +"param.defend_anim"="seq.human_axe_block" "param.attackrate"=5 @@ -51595,7 +52261,6 @@ tradeable = false [[item]] id = "obj.skillcape_max_assembler_masori" inherit = "obj.skillcape_max_assembler_masori" -contentGroup = "content.max_cape" tradeable = false [item.params] "param.ammo_recovery_rate"=80 @@ -51695,6 +52360,15 @@ inherit = "obj.trailblazer_reloaded_harpoon_no_infernal" weaponCategory="StabSword" tradeable = false [item.params] +"param.attack_anim_stance1"="seq.human_sword_stab" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_sword_stab" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_sword_slash" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_sword_stab" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_sword_def" "param.attackrate"=5 @@ -51908,8 +52582,18 @@ inherit = "obj.deadman_blighted_dark_bow" weaponCategory="Bow" [item.params] "param.proj_type"="projanim.arrow" -"param.attack_range"=10 +"param.equipment_sound"=2244 "param.attackrate"=6 +"param.statreq1_skill"="stat.ranged" +"param.attack_range"=10 +"param.attack_anim_stance1"="seq.human_bow" +"param.statreq1_level"=60 +"param.attack_sound_stance1"=3731 +"param.attack_anim_stance2"="seq.human_bow" +"param.attack_sound_stance2"=3731 +"param.attack_anim_stance4"="seq.human_bow" +"param.attack_sound_stance4"=3731 +"param.defend_anim"="seq.human_unarmedblock" [[item]] @@ -52202,6 +52886,15 @@ inherit = "obj.trailblazer_reloaded_harpoon" weaponCategory="StabSword" tradeable = false [item.params] +"param.attack_anim_stance1"="seq.human_sword_stab" +"param.attack_sound_stance1"=2549 +"param.attack_anim_stance2"="seq.human_sword_stab" +"param.attack_sound_stance2"=2549 +"param.attack_anim_stance3"="seq.human_sword_slash" +"param.attack_sound_stance3"=2548 +"param.attack_anim_stance4"="seq.human_sword_stab" +"param.attack_sound_stance4"=2549 +"param.defend_anim"="seq.human_sword_def" "param.attackrate"=5 @@ -52348,6 +53041,17 @@ id = "obj.emberlight" inherit = "obj.emberlight" weaponCategory="SlashSword" tradeable = false +[item.params] +"param.equipment_sound"=2248 +"param.attack_anim_stance1"="seq.human_sword_slash" +"param.attack_sound_stance1"=2500 +"param.attack_anim_stance2"="seq.human_sword_slash" +"param.attack_sound_stance2"=2500 +"param.attack_anim_stance3"="seq.human_sword_stab" +"param.attack_sound_stance3"=2501 +"param.attack_anim_stance4"="seq.human_sword_slash" +"param.attack_sound_stance4"=2500 +"param.defend_anim"="seq.human_sword_def" [[item]] @@ -52510,6 +53214,16 @@ inherit = "obj.trailblazer_reloaded_pickaxe_empty" weaponCategory="Pickaxe" tradeable = false [item.params] +"param.equipment_sound"=2229 +"param.attack_anim_stance1"="seq.human_blunt_pound" +"param.attack_sound_stance1"=2498 +"param.attack_anim_stance2"="seq.human_blunt_pound" +"param.attack_sound_stance2"=2498 +"param.attack_anim_stance3"="seq.human_blunt_spike" +"param.attack_sound_stance3"=2497 +"param.attack_anim_stance4"="seq.human_blunt_pound" +"param.attack_sound_stance4"=2498 +"param.defend_anim"="seq.human_axe_block" "param.attackrate"=5 @@ -52627,6 +53341,17 @@ tradeable = false id = "obj.deadman_blighted_dragon_claws" inherit = "obj.deadman_blighted_dragon_claws" weaponCategory="Claw" +tradeable = false +[item.params] +"param.attack_anim_stance1"="seq.human_axe_chop" +"param.attack_sound_stance1"=4139 +"param.attack_anim_stance2"="seq.human_axe_chop" +"param.attack_sound_stance2"=4139 +"param.attack_anim_stance3"="seq.d_claws_punch" +"param.attack_sound_stance3"=4139 +"param.attack_anim_stance4"="seq.human_axe_chop" +"param.attack_sound_stance4"=4139 +"param.defend_anim"="seq.human_unarmedblock" [[item]] @@ -52759,76 +53484,48 @@ inherit = "obj.softclay" contentGroup = "content.ore" [[item]] -id = "obj.skillcape_max_firecape" -inherit = "obj.skillcape_max_firecape" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_saradomin" -inherit = "obj.skillcape_max_saradomin" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_zamorak" -inherit = "obj.skillcape_max_zamorak" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_guthix" -inherit = "obj.skillcape_max_guthix" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_ardy" -inherit = "obj.skillcape_max_ardy" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_infernalcape" -inherit = "obj.skillcape_max_infernalcape" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_saradomin2" -inherit = "obj.skillcape_max_saradomin2" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_zamorak2" -inherit = "obj.skillcape_max_zamorak2" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_guthix2" -inherit = "obj.skillcape_max_guthix2" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_infernalcape_trouver" -inherit = "obj.skillcape_max_infernalcape_trouver" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_firecape_trouver" -inherit = "obj.skillcape_max_firecape_trouver" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_saradomin2_trouver" -inherit = "obj.skillcape_max_saradomin2_trouver" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_zamorak2_trouver" -inherit = "obj.skillcape_max_zamorak2_trouver" -contentGroup = "content.max_cape" - -[[item]] -id = "obj.skillcape_max_guthix2_trouver" -inherit = "obj.skillcape_max_guthix2_trouver" -contentGroup = "content.max_cape" +id = "obj.toxic_sotd_deadman" +inherit = "obj.toxic_sotd_deadman" +weaponCategory="BladedStaff" +tradeable = false +[item.params] +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" +"param.equipment_sound"=2247 +"param.attack_anim_stance1"="seq.human_spear_spike" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_scythe_sweep" +"param.attack_sound_stance2"=2524 +"param.attack_anim_stance4"="seq.human_stafforb_pummel" +"param.attack_sound_stance4"=2555 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_stafforb_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" [[item]] -id = "obj.skillcape_max_mythical" -inherit = "obj.skillcape_max_mythical" -contentGroup = "content.max_cape" +id = "obj.toxic_sotd_charged_deadman" +inherit = "obj.toxic_sotd_charged_deadman" +weaponCategory="BladedStaff" +tradeable = false +[item.params] +"param.additional_spell_autocast3"=3310 +"param.additional_spell_autocast1"=3293 +"param.additional_spell_autocast2"=4176 +"param.bas_walk_l"="seq.human_halberdwalk_l" +"param.bas_walk_r"="seq.human_halberdwalk_r" +"param.bas_running"="seq.human_halberdrunning" +"param.equipment_sound"=2247 +"param.attack_anim_stance1"="seq.human_spear_spike" +"param.attack_sound_stance1"=2562 +"param.attack_anim_stance2"="seq.human_scythe_sweep" +"param.attack_sound_stance2"=2524 +"param.attack_anim_stance4"="seq.human_stafforb_pummel" +"param.attack_sound_stance4"=2555 +"param.bas_readyanim"="seq.human_staffready" +"param.bas_turnonspot"="seq.human_halberdturnonspot" +"param.defend_anim"="seq.human_stafforb_block" +"param.bas_walk_f"="seq.human_halberdwalk_f" +"param.bas_walk_b"="seq.human_halberdwalk_b" diff --git a/.data/raw-cache/server/projectiles.toml b/.data/raw-cache/server/projectiles.toml index f1593ff78..3daf4d779 100644 --- a/.data/raw-cache/server/projectiles.toml +++ b/.data/raw-cache/server/projectiles.toml @@ -42,6 +42,23 @@ lengthAdjustment = 0 progress = 11 stepMultiplier = 5 +# Dedicated type for Toxic Siphon's own flying-dart effect. Registered as a real alias in +# .data/gamevals/projanim.rscm (a new custom projanim id needs an entry there, not just a raw +# `projectiles.toml` block - confirmed the hard way). `projanim.thrown` (163/146) looked too high +# for this effect; `projanim.dragonfire`'s values (43/31) were tried as an interim reference and +# reported too low; 95/70 was reported still slightly low. Nudged up a little further - still an +# unverified visual-tuning guess, needs live confirmation. +[[projectile]] +isServerOnly = true +id = "projanim.toxic_blowpipe_special" +startHeight = 115 +endHeight = 85 +delay = 32 +angle = 15 +lengthAdjustment = 0 +progress = 11 +stepMultiplier = 5 + [[projectile]] isServerOnly = true id = "projanim.doublearrow_one" diff --git a/api/combat/combat-commons/build.gradle.kts b/api/combat/combat-commons/build.gradle.kts index 79fb7483a..a4201cf6f 100644 --- a/api/combat/combat-commons/build.gradle.kts +++ b/api/combat/combat-commons/build.gradle.kts @@ -7,6 +7,7 @@ kotlin { } dependencies { + implementation(libs.guice) implementation(projects.api.mechanics.toxins) implementation(projects.api.config) implementation(projects.api.npc) diff --git a/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/BindEffectService.kt b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/BindEffectService.kt new file mode 100644 index 000000000..5bc5f8369 --- /dev/null +++ b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/BindEffectService.kt @@ -0,0 +1,66 @@ +package org.rsmod.api.combat.commons + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import java.util.WeakHashMap +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.queue.WorldQueueList + +/** + * A shared "bind" duration tracker for melee weapons whose special attacks immobilize a target + * and/or read how long a target is currently bound for (Zamorak godsword's Ice Cleave, Blue Moon + * spear's Break Shackles). + * + * This is deliberately separate from the existing magic freeze-spell mechanic (Snare/Entangle/ + * Bind/Ice Barrage, [CombatEffects.freeze] / `Player.frozen`) - a target frozen by a spell won't + * show a remaining bind duration here, and vice versa. Documented scope limit, not an oversight: + * unifying the two would need deeper access into the existing player timer internals than these + * two weapons' conversions needed. `Player.frozen` targets are still fully handled by the existing + * mechanic for movement-blocking purposes; this service only tracks its own duration count and, + * for NPCs (which have no equivalent flag), also drives [Npc.movementLocked]. + */ +@Singleton +public class BindEffectService @Inject constructor(private val worldQueues: WorldQueueList) { + private val boundUntil = WeakHashMap() + + public fun bind(target: PathingEntity, ticks: Int) { + if (ticks <= 0) { + return + } + val until = ExpiringDurationMath.untilClock(target.currentMapClock, ticks) + val current = boundUntil[target] ?: 0 + if (!ExpiringDurationMath.shouldExtend(until, current)) { + return + } + boundUntil[target] = until + if (target is Npc) { + target.movementLocked = true + } + worldQueues.add(ticks) { clearIfStillExpired(target, until) } + } + + public fun cyclesRemaining(target: PathingEntity): Int { + val until = boundUntil[target] ?: return 0 + return ExpiringDurationMath.remaining(until, target.currentMapClock) + } + + public fun breakBind(target: PathingEntity) { + boundUntil.remove(target) + if (target is Npc) { + target.movementLocked = false + } + } + + private fun clearIfStillExpired(target: PathingEntity, expectedUntil: Int) { + // A newer bind may have been applied (and its own callback already scheduled) after this + // one was queued - only clear if nothing has extended the duration since. + if (!ExpiringDurationMath.isStillCurrent(boundUntil[target], expectedUntil)) { + return + } + boundUntil.remove(target) + if (target is Npc) { + target.movementLocked = false + } + } +} diff --git a/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMath.kt b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMath.kt new file mode 100644 index 000000000..b31b5004e --- /dev/null +++ b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMath.kt @@ -0,0 +1,27 @@ +package org.rsmod.api.combat.commons + +/** + * Pure "until this map clock" duration bookkeeping shared by [BindEffectService] and + * [ShoveStunService] - both track a target's expiry as an absolute map-clock value in a + * `WeakHashMap` and self-reschedule their own clearing via `WorldQueueList`, kept separate from + * that entity/queue state so the actual decisions (extend or ignore? still current or superseded?) + * can be tested without either. + */ +internal object ExpiringDurationMath { + fun untilClock(currentClock: Int, ticks: Int): Int = currentClock + ticks + + /** + * A fresh application only takes effect if it would expire *after* whatever's already active - + * a shorter reapplication must never shorten an existing, longer-running effect. + */ + fun shouldExtend(newUntil: Int, currentUntil: Int): Boolean = newUntil > currentUntil + + fun remaining(until: Int, currentClock: Int): Int = (until - currentClock).coerceAtLeast(0) + + /** + * A delayed clear callback should only actually clear the effect if nothing extended the + * duration after that callback was scheduled - otherwise it would cut a newer application + * short. + */ + fun isStillCurrent(storedUntil: Int?, expectedUntil: Int): Boolean = storedUntil == expectedUntil +} diff --git a/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ShoveStunService.kt b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ShoveStunService.kt new file mode 100644 index 000000000..7193740be --- /dev/null +++ b/api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ShoveStunService.kt @@ -0,0 +1,54 @@ +package org.rsmod.api.combat.commons + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import java.util.WeakHashMap +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.queue.WorldQueueList + +/** + * The Shove special attack (Dragon spear, Zamorakian spear, Zamorakian hasta) stun. Per the wiki's + * own "Stun (status)" page: "Targets affected by Shove will be fully stunned... There is no + * immunity and thus the stuns from Shove can be chained" - deliberately no post-stun immunity + * window here, matching that (torka's original file had an invented one-cycle player immunity that + * doesn't exist in the real game). + * + * Scope limit, documented rather than silently under-delivered: a full stun blocks movement, + * attacking, eating, equipment changes, and spellcasting. This service only drives + * [Npc.movementLocked] for NPC targets (an existing, already-integrated movement-block flag) and + * exposes [isStunned] for other code to check; nothing currently enforces the equivalent for + * player targets (no existing generic "block all actions" flag to hook into), so a stunned player + * can still act - only the knockback and the `isStunned` bookkeeping are guaranteed correct. + */ +@Singleton +public class ShoveStunService @Inject constructor(private val worldQueues: WorldQueueList) { + private val stunnedUntil = WeakHashMap() + + public fun isStunned(target: PathingEntity): Boolean { + val until = stunnedUntil[target] ?: return false + return ExpiringDurationMath.remaining(until, target.currentMapClock) > 0 + } + + public fun applyStun(target: PathingEntity, ticks: Int) { + if (ticks <= 0) { + return + } + val until = ExpiringDurationMath.untilClock(target.currentMapClock, ticks) + stunnedUntil[target] = until + if (target is Npc) { + target.movementLocked = true + } + worldQueues.add(ticks) { clearIfStillExpired(target, until) } + } + + private fun clearIfStillExpired(target: PathingEntity, expectedUntil: Int) { + if (!ExpiringDurationMath.isStillCurrent(stunnedUntil[target], expectedUntil)) { + return + } + stunnedUntil.remove(target) + if (target is Npc) { + target.movementLocked = false + } + } +} diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/AccuracyFormulae.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/AccuracyFormulae.kt index 5fd678c4e..993b2462a 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/AccuracyFormulae.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/AccuracyFormulae.kt @@ -76,9 +76,18 @@ constructor( blockType: MeleeAttackType?, specMultiplier: Double, random: GameRandom, + defenceMultiplier: Double = 1.0, ): Boolean { val hitChance = - getMeleeHitChance(player, target, attackType, attackStyle, blockType, specMultiplier) + getMeleeHitChance( + player, + target, + attackType, + attackStyle, + blockType, + specMultiplier, + defenceMultiplier, + ) return isSuccessfulHit(hitChance, random) } @@ -106,6 +115,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Int = pvnMeleeAccuracy.getHitChance( player = player, @@ -114,6 +124,7 @@ constructor( attackStyle = attackStyle, blockType = blockType, specialMultiplier = specMultiplier, + defenceMultiplier = defenceMultiplier, ) /** @@ -144,9 +155,18 @@ constructor( blockType: MeleeAttackType?, specMultiplier: Double, random: GameRandom, + defenceMultiplier: Double = 1.0, ): Boolean { val hitChance = - getMeleeHitChance(player, target, attackType, attackStyle, blockType, specMultiplier) + getMeleeHitChance( + player, + target, + attackType, + attackStyle, + blockType, + specMultiplier, + defenceMultiplier, + ) return isSuccessfulHit(hitChance, random) } @@ -174,6 +194,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Int = pvpMeleeAccuracy.getHitChance( player = player, @@ -182,6 +203,7 @@ constructor( attackStyle = attackStyle, blockType = blockType, specialMultiplier = specMultiplier, + defenceMultiplier = defenceMultiplier, ) /** 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..982d32a7b 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 @@ -32,6 +32,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specialMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Int = computeHitChance( source = player, @@ -43,6 +44,7 @@ constructor( attackStyle = attackStyle, blockType = blockType, specialMultiplier = specialMultiplier, + defenceMultiplier = defenceMultiplier, ) public fun computeHitChance( @@ -55,6 +57,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specialMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Int { val meleeAttributes = meleeAttributes.collect(source, attackType) @@ -66,7 +69,7 @@ constructor( val attackRoll = (baseAttackRoll * specialMultiplier).toInt() val amascutInvocationLvl = source.vars["varbit.toa_client_raid_level"] - val defenceRoll = + val baseDefenceRoll = computeDefenceRoll( target = target, targetDefence = targetDefence, @@ -74,6 +77,7 @@ constructor( blockType = blockType, npcAttributes = npcAttributes, ) + val defenceRoll = (baseDefenceRoll * defenceMultiplier).toInt() val hitChance = AccuracyOperations.calculateHitChance(attackRoll, defenceRoll) return MeleeAccuracyOperations.modifyHitChance( diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvPMeleeAccuracy.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvPMeleeAccuracy.kt index 0b4cac24f..7be0ff939 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvPMeleeAccuracy.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/accuracy/melee/PvPMeleeAccuracy.kt @@ -27,6 +27,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specialMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Int = computeHitChance( source = player, @@ -35,6 +36,7 @@ constructor( attackStyle = attackStyle, blockType = blockType, specialMultiplier = specialMultiplier, + defenceMultiplier = defenceMultiplier, ) public fun computeHitChance( @@ -44,6 +46,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specialMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Int { val npcAttributes = EnumSet.noneOf(CombatNpcAttributes::class.java) val meleeAttributes = meleeAttributes.collect(source, attackType) @@ -52,7 +55,8 @@ constructor( computeAttackRoll(source, attackType, attackStyle, meleeAttributes, npcAttributes) val attackRoll = (baseAttackRoll * specialMultiplier).toInt() - val defenceRoll = computeDefenceRoll(target, blockType) + val baseDefenceRoll = computeDefenceRoll(target, blockType) + val defenceRoll = (baseDefenceRoll * defenceMultiplier).toInt() val hitChance = AccuracyOperations.calculateHitChance(attackRoll, defenceRoll) return MeleeAccuracyOperations.modifyHitChance( diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/EclipseAtlatlMaxHit.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/EclipseAtlatlMaxHit.kt new file mode 100644 index 000000000..d335cade0 --- /dev/null +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/EclipseAtlatlMaxHit.kt @@ -0,0 +1,61 @@ +package org.rsmod.api.combat.formulas.maxhit.ranged + +import org.rsmod.api.combat.maxhit.player.PlayerMeleeMaxHit +import org.rsmod.api.player.bonus.WornBonuses +import org.rsmod.api.player.hands +import org.rsmod.api.player.hat +import org.rsmod.api.player.legs +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.torso +import org.rsmod.api.player.worn.EquipmentChecks +import org.rsmod.game.entity.Player + +/** + * Eclipse atlatl base damage: a wiki-confirmed unique mechanic among ranged weapons - despite + * being ranged (accuracy still rolls off ranged level/bonus, handled separately), its **damage** + * is computed off the player's **melee** Strength level and melee strength bonus instead of + * ranged strength. It also uniquely reads the *ranged* void helm (not the melee one) for its void + * bonus - "gains increased damage from Void Knight equipment whilst wearing the void ranger helm + * rather than the void melee helm" - and melee strength prayers (Piety etc.) rather than ranged + * ones, since the damage half of the formula is melee-shaped throughout. + * + * None of the atlatl's three combat styles (Accurate/Rapid/Longrange) are a melee-style + * "aggressive" equivalent, so no style grants a bonus to the strength side of the formula here - + * every style uses the same flat baseline `+8` every combat style implicitly gets. + */ +public object EclipseAtlatlMaxHit { + public fun computeBaseDamage(player: Player, bonuses: WornBonuses): Int { + val effectiveStrength = + PlayerMeleeMaxHit.calculateEffectiveStrength( + visibleStrengthLvl = player.stat("stat.strength"), + styleBonus = STYLE_BONUS, + prayerBonus = player.meleeStrengthPrayerBonus(), + voidBonus = player.rangerHelmVoidBonus(), + weaponBonus = 1.0, + ) + val strengthBonus = bonuses.strengthBonus(player) + return PlayerMeleeMaxHit.calculateBaseDamage(effectiveStrength, strengthBonus) + } + + private fun Player.meleeStrengthPrayerBonus(): Double = + when { + vars["varbit.prayer_burstofstrength"] == 1 -> 1.05 + vars["varbit.prayer_superhumanstrength"] == 1 -> 1.1 + vars["varbit.prayer_ultimatestrength"] == 1 -> 1.15 + vars["varbit.prayer_chivalry"] == 1 -> 1.18 + vars["varbit.prayer_piety"] == 1 -> 1.23 + else -> 1.0 + } + + private fun Player.rangerHelmVoidBonus(): Double { + if (!EquipmentChecks.isVoidRangerHelm(hat)) return 1.0 + if (!EquipmentChecks.isVoidGloves(hands)) return 1.0 + return when { + EquipmentChecks.isEliteVoidTop(torso) && EquipmentChecks.isEliteVoidRobe(legs) -> 1.125 + EquipmentChecks.isRegularVoidTop(torso) && EquipmentChecks.isRegularVoidRobe(legs) -> 1.1 + else -> 1.0 + } + } + + private const val STYLE_BONUS = 8 +} 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..3b8203486 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 @@ -15,7 +15,10 @@ import org.rsmod.api.combat.maxhit.player.PlayerRangedMaxHit import org.rsmod.api.combat.weapon.WeaponSpeeds import org.rsmod.api.config.refs.params import org.rsmod.api.player.bonus.WornBonuses +import org.rsmod.api.player.ranged.BlowpipeAmmo +import org.rsmod.api.player.righthand import org.rsmod.api.player.vars.intVarp +import org.rsmod.api.player.worn.EquipmentChecks import org.rsmod.game.entity.Npc import org.rsmod.game.entity.Player @@ -96,9 +99,24 @@ constructor( rangeAttributes: EnumSet, npcAttributes: EnumSet, ): Int { - val effectiveRanged = RangedMaxHitOperations.calculateEffectiveRanged(source, attackStyle) - val rangedBonus = bonuses.rangedStrengthBonus(source) - val baseDamage = PlayerRangedMaxHit.calculateBaseDamage(effectiveRanged, rangedBonus) + // Eclipse atlatl: wiki-confirmed unique mechanic - damage is computed off melee strength + // instead of ranged strength, even though accuracy (rolled elsewhere) stays ranged-based. + val baseDamage = + if (EquipmentChecks.isEclipseAtlatl(source.righthand)) { + EclipseAtlatlMaxHit.computeBaseDamage(source, bonuses) + } else { + val effectiveRanged = + RangedMaxHitOperations.calculateEffectiveRanged(source, attackStyle) + // Toxic/rosewood blowpipe darts are packed inside the weapon's own vars, not + // worn in the quiver, so the standard equipment-bonus scan never sees the loaded + // dart's own ranged strength - add it back in explicitly. A no-op (0) for every + // other weapon, since `loadedDart` returns null unless `source.righthand` is a + // real blowpipe with a dart loaded. + val rangedBonus = + bonuses.rangedStrengthBonus(source) + + BlowpipeAmmo.rangedStrengthBonus(source.righthand) + PlayerRangedMaxHit.calculateBaseDamage(effectiveRanged, rangedBonus) + } return RangedMaxHitOperations.modifyBaseDamage( baseDamage = baseDamage, targetMagic = targetMagic, diff --git a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvPRangedMaxHit.kt b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvPRangedMaxHit.kt index 2e4ddac61..780053b67 100644 --- a/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvPRangedMaxHit.kt +++ b/api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/PvPRangedMaxHit.kt @@ -12,7 +12,10 @@ import org.rsmod.api.combat.formulas.maxhit.MaxHitOperations import org.rsmod.api.combat.maxhit.player.PlayerRangedMaxHit import org.rsmod.api.combat.weapon.WeaponSpeeds import org.rsmod.api.player.bonus.WornBonuses +import org.rsmod.api.player.ranged.BlowpipeAmmo +import org.rsmod.api.player.righthand import org.rsmod.api.player.vars.intVarp +import org.rsmod.api.player.worn.EquipmentChecks import org.rsmod.api.random.GameRandom import org.rsmod.game.entity.Player @@ -85,9 +88,23 @@ constructor( attackStyle: RangedAttackStyle?, rangeAttributes: EnumSet, ): Int { - val effectiveRanged = RangedMaxHitOperations.calculateEffectiveRanged(source, attackStyle) - val rangedBonus = bonuses.rangedStrengthBonus(source) - val baseDamage = PlayerRangedMaxHit.calculateBaseDamage(effectiveRanged, rangedBonus) + // Eclipse atlatl: wiki-confirmed unique mechanic - damage is computed off melee strength + // instead of ranged strength, even though accuracy (rolled elsewhere) stays ranged-based. + val baseDamage = + if (EquipmentChecks.isEclipseAtlatl(source.righthand)) { + EclipseAtlatlMaxHit.computeBaseDamage(source, bonuses) + } else { + val effectiveRanged = + RangedMaxHitOperations.calculateEffectiveRanged(source, attackStyle) + // Toxic/rosewood blowpipe darts are packed inside the weapon's own vars, not + // worn in the quiver, so the standard equipment-bonus scan never sees the loaded + // dart's own ranged strength - add it back in explicitly. A no-op (0) for every + // other weapon. + val rangedBonus = + bonuses.rangedStrengthBonus(source) + + BlowpipeAmmo.rangedStrengthBonus(source.righthand) + PlayerRangedMaxHit.calculateBaseDamage(effectiveRanged, rangedBonus) + } return RangedMaxHitOperations.modifyBaseDamage(baseDamage, rangeAttributes) } diff --git a/api/combat/combat-manager/src/main/kotlin/org/rsmod/api/combat/manager/PlayerAttackManager.kt b/api/combat/combat-manager/src/main/kotlin/org/rsmod/api/combat/manager/PlayerAttackManager.kt index 223c6e0a2..e0954abd5 100644 --- a/api/combat/combat-manager/src/main/kotlin/org/rsmod/api/combat/manager/PlayerAttackManager.kt +++ b/api/combat/combat-manager/src/main/kotlin/org/rsmod/api/combat/manager/PlayerAttackManager.kt @@ -5,7 +5,6 @@ import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType import dev.openrune.types.ItemServerType import dev.openrune.types.SequenceServerType -import dev.openrune.types.StatType import dev.openrune.types.aconverted.SpotanimType import dev.openrune.types.aconverted.SynthType import jakarta.inject.Inject @@ -33,8 +32,10 @@ import org.rsmod.api.config.refs.params import org.rsmod.api.death.PvPPlayerHitHook import org.rsmod.api.npc.hit.modifier.NpcHitModifier import org.rsmod.api.npc.hit.queueHit -import org.rsmod.api.player.hit.queueHit import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.hit.modifier.PlayerHitModifier +import org.rsmod.api.player.hit.modifier.StandardPlayerHitModifier +import org.rsmod.api.player.hit.queueHit import org.rsmod.api.player.interact.NpcInteractions import org.rsmod.api.player.interact.NpcTInteractions import org.rsmod.api.player.interact.PlayerInteractions @@ -201,7 +202,7 @@ constructor( val attackAnim = weapon?.paramOrNull(animParam) ?: SequenceServerType(defaultAnim.asRSCM(RSCMType.SEQ)) val attackSound = weapon?.paramOrNull(soundParam) ?: SynthType(defaultSound.asRSCM(RSCMType.SYNTH)) - player.anim(RSCM.getReverseMapping(RSCMType.SEQ,attackAnim.id), priority = 6) + player.anim(RSCM.getReverseMapping(RSCMType.SEQ, attackAnim.id), priority = 6) player.soundSynth(attackSound) } @@ -218,7 +219,7 @@ constructor( public fun playWeaponFx(player: Player, attack: CombatAttack.Ranged): Boolean { val weapon = getInvObj(attack.weapon) val attackAnim = weapon.paramOrNull(params.attack_anim_stance1) ?: return false - player.anim(RSCM.getReverseMapping(RSCMType.SEQ,attackAnim.id), priority = 6) + player.anim(RSCM.getReverseMapping(RSCMType.SEQ, attackAnim.id), priority = 6) val attackSound = weapon.paramOrNull(params.attack_sound_stance1) attackSound?.let(player::soundSynth) return true @@ -540,16 +541,33 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, multiplier: Double, + defenceMultiplier: Double = 1.0, ): Boolean { if (source.adminMaxHit) { return true } return when (target) { is Npc -> { - rollMeleeAccuracy(source, target, attackType, attackStyle, blockType, multiplier) + rollMeleeAccuracy( + source, + target, + attackType, + attackStyle, + blockType, + multiplier, + defenceMultiplier, + ) } is Player -> { - rollMeleeAccuracy(source, target, attackType, attackStyle, blockType, multiplier) + rollMeleeAccuracy( + source, + target, + attackType, + attackStyle, + blockType, + multiplier, + defenceMultiplier, + ) } } } @@ -561,6 +579,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Boolean = accuracy.rollMeleeAccuracy( player = source, @@ -570,6 +589,7 @@ constructor( blockType = blockType, specMultiplier = specMultiplier, random = random, + defenceMultiplier = defenceMultiplier, ) private fun rollMeleeAccuracy( @@ -579,6 +599,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Boolean = accuracy.rollMeleeAccuracy( player = source, @@ -588,6 +609,7 @@ constructor( blockType = blockType, specMultiplier = specMultiplier, random = random, + defenceMultiplier = defenceMultiplier, ) /** @@ -697,10 +719,11 @@ constructor( target: PathingEntity, damage: Int, delay: Int = 1, + modifier: PlayerHitModifier = StandardPlayerHitModifier, ): Hit = when (target) { is Npc -> queueMeleeHit(source, target, damage, delay) - is Player -> queueMeleeHit(source, target, damage, delay) + is Player -> queueMeleeHit(source, target, damage, delay, modifier) } private fun queueMeleeHit(source: Player, target: Npc, damage: Int, delay: Int): Hit { @@ -714,13 +737,19 @@ constructor( return hit } - private fun queueMeleeHit(source: Player, target: Player, damage: Int, delay: Int): Hit { + private fun queueMeleeHit( + source: Player, + target: Player, + damage: Int, + delay: Int, + modifier: PlayerHitModifier = StandardPlayerHitModifier, + ): Hit { // Note: Retaliation must be queued _before_ the hit. If queued after, every hit would // trigger the "speed-up" death mechanic, since the hit queues would no longer be the // last entries in the queue list at the time of processing. target.queueCombatRetaliate(source) - val hit = target.queueHit(source, delay, HitType.Melee, damage) + val hit = target.queueHit(source, delay, HitType.Melee, damage, modifier = modifier) notifyPlayerHit(source, target) target.combatPlayDefendAnim() return hit diff --git a/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvNCombat.kt b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvNCombat.kt index 5e4bd477f..76b665852 100644 --- a/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvNCombat.kt +++ b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvNCombat.kt @@ -3,8 +3,6 @@ package org.rsmod.api.combat import dev.openrune.rscm.RSCM import dev.openrune.rscm.RSCMType import jakarta.inject.Inject -import org.rsmod.api.death.NpcAttackValidateHook -import org.rsmod.api.death.NpcAttackValidateResult import org.rsmod.api.combat.commons.CombatAttack import org.rsmod.api.combat.manager.PlayerAttackManager import org.rsmod.api.combat.manager.RangedAmmoManager @@ -16,11 +14,16 @@ import org.rsmod.api.combat.player.specialAttackType import org.rsmod.api.combat.weapon.WeaponSpeeds import org.rsmod.api.config.constants import org.rsmod.api.config.refs.params +import org.rsmod.api.death.NpcAttackValidateHook +import org.rsmod.api.death.NpcAttackValidateResult +import org.rsmod.api.mechanics.toxins.EclipseAtlatlBurnEffect +import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect import org.rsmod.api.npc.isValidTarget import org.rsmod.api.player.lefthand import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.player.quiver import org.rsmod.api.player.righthand +import org.rsmod.api.specials.NextCycleRangedSpecialTiming import org.rsmod.api.specials.SpecialAttackRegistry import org.rsmod.api.specials.SpecialAttackType import org.rsmod.api.specials.energy.SpecialAttackEnergy @@ -44,6 +47,8 @@ constructor( private val ammunition: RangedAmmoManager, private val spellsReg: SpellAttackRegistry, private val attackValidateHooks: Set, + private val weaponPoison: WeaponPoisonEffect, + private val eclipseAtlatlBurn: EclipseAtlatlBurnEffect, ) { suspend fun attack(access: ProtectedAccess, target: Npc, attack: CombatAttack.PlayerAttack) { when (attack) { @@ -101,6 +106,7 @@ constructor( manager.giveCombatXp(player, npc, attack, damage) manager.playWeaponFx(player, attack) manager.queueMeleeHit(player, npc, damage) + attack.weapon?.let { weaponPoison.rollOnMeleeHit(player, npc, getInvObj(it), damage) } manager.continueCombat(player, npc) } @@ -109,7 +115,21 @@ constructor( return } - if (manager.isAttackDelayed(player)) { + val timing = + NextCycleRangedSpecialTiming.resolve( + player = player, + weaponId = attack.weapon.id, + specialSelected = specialAttackType == SpecialAttackType.Weapon, + ) + if (timing == NextCycleRangedSpecialTiming.Resolution.Wait) { + manager.continueCombat(player, npc) + return + } + + if ( + manager.isAttackDelayed(player) && + timing != NextCycleRangedSpecialTiming.Resolution.BypassAttackDelay + ) { manager.continueCombat(player, npc) return } @@ -187,7 +207,7 @@ constructor( return } - val projanimType = RSCM.getReverseMapping(RSCMType.PROJANIM,projectileID) + val projanimType = RSCM.getReverseMapping(RSCMType.PROJANIM, projectileID) // All valid ranged weapons require an `attack_anim_stance1` seq type param to be used in // combat. @@ -202,8 +222,8 @@ constructor( // has no `proj_launch` param, a "null" (-1) spotanim will still be sent in the same slot // and height as usual. val launchSpotanim = weaponType.paramOrNull(params.proj_launch)?.id ?: NULL_SPOTANIM_ID - - val launchSpotanimName = launchSpotanim.takeUnless { it == NULL_SPOTANIM_ID } ?.let { RSCM.getReverseMapping(RSCMType.SPOTANIM, it) } + + val launchSpotanimName = launchSpotanim.takeUnless { it == NULL_SPOTANIM_ID }?.let { RSCM.getReverseMapping(RSCMType.SPOTANIM, it) } spotanim(launchSpotanimName, height = 96, slot = constants.spotanim_slot_combat) val projanim = manager.spawnProjectile(player, npc, travelSpotanim, projanimType) @@ -220,6 +240,8 @@ constructor( val hitAmmoObj = if (usingThrown) null else quiverType manager.queueRangedHit(player, npc, hitAmmoObj, damage, clientDelay, serverDelay) + weaponPoison.rollOnRangedHit(player, npc, weaponType, damage) + eclipseAtlatlBurn.rollOnHit(player, npc, damage) if (usingThrown && player.righthand == null) { mes("That was your last one!") @@ -242,7 +264,24 @@ constructor( val attackRate = MAGIC_SPELL_ATTACK_RATE manager.setNextAttackDelay(player, attackRate) - val spell = spellsReg[RSCM.getReverseMapping(RSCMType.OBJ,attack.spell.obj.id)] + // A weapon can be both autocastable and carry its own magic special attack (e.g. the + // Nightmare staffs). `resolveCombatAttack` resolves an active autocast to + // `CombatAttack.Spell` regardless of the special-attack toggle, so that toggle has to be + // handled here too - otherwise activating a special while autocasting just casts the + // autocast spell instead, same as it did for the melee/staff resolution paths. + if (specialAttackType == SpecialAttackType.Weapon) { + specialAttackType = SpecialAttackType.None + val weapon = attack.weapon + if (weapon != null) { + val staffAttack = CombatAttack.Staff(weapon, style = null) + val activatedSpec = activateMagicSpecial(npc, staffAttack, specialsReg, specialEnergy) + if (activatedSpec) { + return + } + } + } + + val spell = spellsReg[RSCM.getReverseMapping(RSCMType.OBJ, attack.spell.obj.id)] if (spell != null) { spell.attack(this, npc, attack) return diff --git a/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvPCombat.kt b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvPCombat.kt index 9f9131368..7c7e5824a 100644 --- a/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvPCombat.kt +++ b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/PvPCombat.kt @@ -12,16 +12,19 @@ import org.rsmod.api.combat.player.activateRangedSpecial import org.rsmod.api.combat.player.activateShieldSpecial import org.rsmod.api.combat.player.setPkVars import org.rsmod.api.combat.player.specialAttackType -import org.rsmod.api.death.PvPSkullHook -import org.rsmod.api.death.PvPSpecialAttackHook import org.rsmod.api.combat.weapon.WeaponSpeeds import org.rsmod.api.config.constants import org.rsmod.api.config.refs.params +import org.rsmod.api.death.PvPSkullHook +import org.rsmod.api.death.PvPSpecialAttackHook +import org.rsmod.api.mechanics.toxins.EclipseAtlatlBurnEffect +import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect import org.rsmod.api.player.isValidTarget import org.rsmod.api.player.lefthand import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.player.quiver import org.rsmod.api.player.righthand +import org.rsmod.api.specials.NextCycleRangedSpecialTiming import org.rsmod.api.specials.SpecialAttackRegistry import org.rsmod.api.specials.SpecialAttackType import org.rsmod.api.specials.energy.SpecialAttackEnergy @@ -45,6 +48,8 @@ constructor( private val spellsReg: SpellAttackRegistry, private val skullHooks: Set, private val specialAttackHooks: Set, + private val weaponPoison: WeaponPoisonEffect, + private val eclipseAtlatlBurn: EclipseAtlatlBurnEffect, ) { suspend fun attack(access: ProtectedAccess, target: Player, attack: CombatAttack.PlayerAttack) { when (attack) { @@ -126,6 +131,7 @@ constructor( manager.giveCombatXp(player, target, attack, damage) manager.playWeaponFx(player, attack) manager.queueMeleeHit(player, target, damage) + attack.weapon?.let { weaponPoison.rollOnMeleeHit(player, target, getInvObj(it), damage) } manager.continueCombat(player, target) } @@ -134,7 +140,21 @@ constructor( return } - if (manager.isAttackDelayed(player)) { + val timing = + NextCycleRangedSpecialTiming.resolve( + player = player, + weaponId = attack.weapon.id, + specialSelected = specialAttackType == SpecialAttackType.Weapon, + ) + if (timing == NextCycleRangedSpecialTiming.Resolution.Wait) { + manager.continueCombat(player, target) + return + } + + if ( + manager.isAttackDelayed(player) && + timing != NextCycleRangedSpecialTiming.Resolution.BypassAttackDelay + ) { manager.continueCombat(player, target) return } @@ -220,7 +240,7 @@ constructor( return } - val projanimType = RSCM.getReverseMapping(RSCMType.PROJANIM,projectileID) + val projanimType = RSCM.getReverseMapping(RSCMType.PROJANIM, projectileID) // All valid ranged weapons require an `attack_anim_stance1` seq type param to be used in // combat. @@ -237,7 +257,7 @@ constructor( // has no `proj_launch` param, a "null" (-1) spotanim will still be sent in the same slot // and height as usual. val launchSpotanim = weaponType.paramOrNull(params.proj_launch)?.id ?: NULL_SPOTANIM_ID - player.spotanim(RSCM.getReverseMapping(RSCMType.SPOTANIM,launchSpotanim), height = 96, slot = constants.spotanim_slot_combat) + player.spotanim(RSCM.getReverseMapping(RSCMType.SPOTANIM, launchSpotanim), height = 96, slot = constants.spotanim_slot_combat) val projanim = manager.spawnProjectile(player, target, travelSpotanim, projanimType) val (serverDelay, clientDelay) = projanim.durations @@ -258,6 +278,8 @@ constructor( val hitAmmoObj = if (usingThrown) null else quiverType manager.queueRangedHit(player, target, hitAmmoObj, damage, clientDelay, serverDelay) + weaponPoison.rollOnRangedHit(player, target, weaponType, damage) + eclipseAtlatlBurn.rollOnHit(player, target, damage) if (usingThrown && player.righthand == null) { mes("That was your last one!") @@ -283,7 +305,27 @@ constructor( val attackRate = MAGIC_SPELL_ATTACK_RATE manager.setNextAttackDelay(player, attackRate) - val spell = spellsReg[RSCM.getReverseMapping(RSCMType.OBJ,attack.spell.obj.id)] + // A weapon can be both autocastable and carry its own magic special attack (e.g. the + // Nightmare staffs). `resolveCombatAttack` resolves an active autocast to + // `CombatAttack.Spell` regardless of the special-attack toggle, so that toggle has to be + // handled here too - otherwise activating a special while autocasting just casts the + // autocast spell instead, same as it did for the melee/staff resolution paths. + if (specialAttackType == SpecialAttackType.Weapon) { + specialAttackType = SpecialAttackType.None + val weapon = attack.weapon + if (weapon != null) { + val staffAttack = CombatAttack.Staff(weapon, style = null) + val activatedSpec = + activateMagicSpecial(target, staffAttack, specialsReg, specialEnergy) + if (activatedSpec) { + applySpecialAttackHooks(target) + applyPkVars(target) + return + } + } + } + + val spell = spellsReg[RSCM.getReverseMapping(RSCMType.OBJ, attack.spell.obj.id)] if (spell != null) { applyPkVars(target) spell.attack(this, target, attack) diff --git a/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PlayerCommons.kt b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PlayerCommons.kt index c635d1aa8..7e435692c 100644 --- a/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PlayerCommons.kt +++ b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PlayerCommons.kt @@ -135,6 +135,17 @@ internal suspend fun ProtectedAccess.activateMeleeSpecial( ): Boolean { val weapon = attack.weapon ?: return false val special = specials[weapon] ?: return false + + // A handful of weapons (e.g. the Nightmare staffs) attack normally with melee - weaponCategory + // `Staff` resolves to a melee `AttackType` via `AttackTypes` - but their special attack is + // magic-based. `resolveCombatAttack` has no way to know that ahead of time, so it always + // resolves these as `CombatAttack.Melee`. Detect that mismatch here and redirect into the + // magic special path instead of silently failing and falling through to a normal attack. + if (special is SpecialAttack.Magic) { + val staffAttack = CombatAttack.Staff(weapon, style = null) + return activateMagicSpecial(target, staffAttack, specials, energy) + } + if (special !is SpecialAttack.Melee) { return false } @@ -153,7 +164,10 @@ internal suspend fun ProtectedAccess.activateMeleeSpecial( // interaction will either be canceled entirely or proceed with the delay. val reduceEnergy = special.attack(this, target, attack) - if (reduceEnergy) { + // Re-checked rather than taken unconditionally: a non-specialized weapon's cost was already + // validated above, but re-validating here means a change to the player's energy during the + // special's own execution can never turn into an uncaught `takeSpecialEnergy` exception. + if (reduceEnergy && energy.hasSpecialEnergy(player, special.energyInHundreds)) { energy.takeSpecialEnergy(player, special.energyInHundreds) } return true @@ -184,7 +198,8 @@ internal suspend fun ProtectedAccess.activateRangedSpecial( // interaction will either be canceled entirely or proceed with the delay. val reduceEnergy = special.attack(this, target, attack) - if (reduceEnergy) { + // Re-checked rather than taken unconditionally - see `activateMeleeSpecial`. + if (reduceEnergy && energy.hasSpecialEnergy(player, special.energyInHundreds)) { energy.takeSpecialEnergy(player, special.energyInHundreds) } return true @@ -215,7 +230,8 @@ internal suspend fun ProtectedAccess.activateMagicSpecial( // interaction will either be canceled entirely or proceed with the delay. val reduceEnergy = special.attack(this, target, attack) - if (reduceEnergy) { + // Re-checked rather than taken unconditionally - see `activateMeleeSpecial`. + if (reduceEnergy && energy.hasSpecialEnergy(player, special.energyInHundreds)) { energy.takeSpecialEnergy(player, special.energyInHundreds) } return true diff --git a/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PvPAreaAttackManager.kt b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PvPAreaAttackManager.kt new file mode 100644 index 000000000..3d951d344 --- /dev/null +++ b/api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PvPAreaAttackManager.kt @@ -0,0 +1,52 @@ +package org.rsmod.api.combat.player + +import jakarta.inject.Inject +import org.rsmod.api.death.PvPAttackValidateHook +import org.rsmod.api.death.PvPAttackValidateResult +import org.rsmod.api.death.PvPSkullHook +import org.rsmod.api.death.PvPSpecialAttackHook +import org.rsmod.api.player.isValidTarget +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.game.entity.Player + +/** + * PvP legality and side effects for area-of-effect special attacks (halberd sweeps, Dinh's shield + * bash, Rune thrownaxe's chain, etc.) that can hit players other than the one the wielder + * interacted with. + * + * The primary target already goes through [org.rsmod.api.combat.PvPCombat]'s own attack-validate + * hooks and pk-var/skull bookkeeping before an area special ever runs. Every other player caught + * in the area skips that path entirely, so this reapplies the same two pieces for them: the + * [PvPAttackValidateHook] legality checks (wilderness level range, Ferox Enclave, would-skull + * prevention, etc.) via [canAttack], and the pk-var/skull/special-attack-hook bookkeeping via + * [applySecondarySpecialAttack]. + */ +public class PvPAreaAttackManager +@Inject +constructor( + private val attackValidateHooks: Set, + private val skullHooks: Set, + private val specialAttackHooks: Set, +) { + public fun canAttack(source: Player, target: Player): Boolean { + if (!target.isValidTarget()) { + return false + } + for (hook in attackValidateHooks) { + if (hook.validate(source, target) is PvPAttackValidateResult.Deny) { + return false + } + } + return true + } + + public fun applySecondarySpecialAttack(source: ProtectedAccess, target: Player) { + for (hook in skullHooks) { + hook.onPlayerAttack(source.player, target) + } + source.setPkVars(target) + for (hook in specialAttackHooks) { + hook.onPlayerSpecialAttack(source.player, target) + } + } +} diff --git a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerRunUpdateProcessor.kt b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerRunUpdateProcessor.kt index 94bc581fe..f18e6952e 100644 --- a/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerRunUpdateProcessor.kt +++ b/api/game-process/src/main/kotlin/org/rsmod/api/game/process/player/PlayerRunUpdateProcessor.kt @@ -6,6 +6,7 @@ import kotlin.math.min import org.rsmod.api.config.constants import org.rsmod.api.config.refs.params import org.rsmod.api.inv.weight.InvWeight +import org.rsmod.api.player.hit.modifier.MorriganHamstring import org.rsmod.api.player.output.UpdateRun import org.rsmod.api.player.stat.agilityLvl import org.rsmod.api.player.vars.setActiveMoveSpeed @@ -50,6 +51,9 @@ public class PlayerRunUpdateProcessor { } else if (hasStaminaEffect()) { loss = (loss * 30) / 100 } + if (MorriganHamstring.isActive(this)) { + loss *= MorriganHamstring.RUN_ENERGY_DRAIN_MULTIPLIER + } runEnergy = max(0, runEnergy - loss) if (runEnergy == 0 && isRunning()) { diff --git a/api/mechanics/toxins/build.gradle.kts b/api/mechanics/toxins/build.gradle.kts index 3ac44c7d3..33a1e18c8 100644 --- a/api/mechanics/toxins/build.gradle.kts +++ b/api/mechanics/toxins/build.gradle.kts @@ -10,8 +10,10 @@ dependencies { implementation(libs.guice) implementation(projects.api.attr) implementation(projects.api.config) + implementation(projects.api.npc) implementation(projects.api.player) implementation(projects.api.playerOutput) + implementation(projects.api.random) implementation(projects.api.script) implementation(projects.engine.events) implementation(projects.engine.game) diff --git a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/BurnEffectService.kt b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/BurnEffectService.kt new file mode 100644 index 000000000..a7a1c6f4e --- /dev/null +++ b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/BurnEffectService.kt @@ -0,0 +1,138 @@ +package org.rsmod.api.mechanics.toxins + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import java.util.WeakHashMap +import org.rsmod.api.config.refs.done.hitmark_groups +import org.rsmod.api.npc.hit.modifier.NpcHitModifier +import org.rsmod.api.npc.hit.queueHit +import org.rsmod.api.npc.isValidTarget +import org.rsmod.api.player.hit.modifier.NoopPlayerHitModifier +import org.rsmod.api.player.hit.queueHit +import org.rsmod.api.player.isValidTarget +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.queue.WorldQueueList + +/** + * Burn: a stacking damage-over-time status effect. Wiki: entities take 1 damage every 4 ticks; a + * normal burn instance lasts 40 ticks (10 total damage). Up to 5 independent burn instances can be + * active on the same target at once - the tick frequency never changes, but each tick deals damage + * equal to the number of currently active stacks, and each stack still expires on its own + * schedule. A 6th application while already at 5 stacks is discarded (the oldest is not replaced). + * + * Coded as typeless damage here (not ranged, despite the wiki calling it "coded as ranged damage" + * for NPC damage-style-reduction purposes) specifically so Protect from Missiles doesn't reduce + * it, matching the wiki's explicit carve-out ("The Protect from Missiles prayer will not reduce + * burn damage"). NPC ranged-immunity interactions and burn severity tiers (Incendiary/Strong/ + * Normal/Weak) are not modeled - a lightweight, in-memory implementation with no buff-bar icon or + * persistence across logout, matching the scope of this session's other timed-effect additions. + */ +@Singleton +public class BurnEffectService +@Inject +constructor( + private val worldQueues: WorldQueueList, + private val npcHitModifier: NpcHitModifier, +) { + private val activeStacks = WeakHashMap>() + + /** + * Consumes every active burn stack on [target] and returns the total damage they would have + * dealt over their remaining lifetime (each stack "owes" its remaining ticks / [TICK_INTERVAL] + * worth of 1-damage pulses). Ends the burn entirely - used by effects that convert unspent burn + * damage into a one-off bonus (Eclipse atlatl's own special attack). + */ + public fun consumeRemainingDamage(target: PathingEntity): Int { + val stacks = activeStacks.remove(target) ?: return 0 + return BurnStacks.remainingDamage(stacks) + } + + public fun apply(source: Player, target: PathingEntity) { + val stacks = activeStacks.getOrPut(target) { mutableListOf() } + if (!BurnStacks.canApply(stacks.size)) { + return + } + val alreadyTicking = stacks.isNotEmpty() + stacks += BURN_DURATION_TICKS + if (!alreadyTicking) { + scheduleTick(target) + } + } + + private fun scheduleTick(target: PathingEntity) { + worldQueues.add(TICK_INTERVAL) { + val stacks = activeStacks[target] + if (stacks.isNullOrEmpty() || !target.isBurnable()) { + activeStacks.remove(target) + return@add + } + + dealBurnDamage(target, damage = BurnStacks.damageForStackCount(stacks.size)) + + val remaining = BurnStacks.tick(stacks) + stacks.clear() + stacks += remaining + + if (stacks.isEmpty()) { + activeStacks.remove(target) + } else { + scheduleTick(target) + } + } + } + + private fun dealBurnDamage(target: PathingEntity, damage: Int) { + when (target) { + is Player -> + target.queueHit( + delay = 1, + type = HitType.Typeless, + damage = damage, + hitmark = hitmark_groups.burn, + modifier = NoopPlayerHitModifier, + ) + is Npc -> + target.queueHit( + delay = 1, + type = HitType.Typeless, + damage = damage, + modifier = npcHitModifier, + hitmark = hitmark_groups.burn, + ) + } + } + + private fun PathingEntity.isBurnable(): Boolean = + when (this) { + is Player -> isValidTarget() + is Npc -> isValidTarget() + } + + private companion object { + const val TICK_INTERVAL: Int = BurnStacks.TICK_INTERVAL + const val BURN_DURATION_TICKS: Int = BurnStacks.DURATION_TICKS + const val MAX_STACKS: Int = BurnStacks.MAX_STACKS + } +} + +/** Pure burn-stack state transitions, kept separate from [PathingEntity]/[WorldQueueList] state. */ +internal object BurnStacks { + const val TICK_INTERVAL: Int = 4 + const val DURATION_TICKS: Int = 40 + const val MAX_STACKS: Int = 5 + + /** A 6th application while already at [MAX_STACKS] is discarded, not replacing the oldest. */ + fun canApply(currentStackCount: Int): Boolean = currentStackCount < MAX_STACKS + + /** Damage dealt on a pulse is the number of currently active stacks, capped at [MAX_STACKS]. */ + fun damageForStackCount(stackCount: Int): Int = stackCount.coerceAtMost(MAX_STACKS) + + /** Decrements every stack by one pulse and drops any that have fully expired. */ + fun tick(stacks: List): List = stacks.map { it - TICK_INTERVAL }.filter { it > 0 } + + /** Each stack "owes" its remaining ticks worth of future 1-damage pulses. */ + fun remainingDamage(stacks: List): Int = stacks.sumOf { it / TICK_INTERVAL } +} diff --git a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/EclipseAtlatlBurnEffect.kt b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/EclipseAtlatlBurnEffect.kt new file mode 100644 index 000000000..9101636bd --- /dev/null +++ b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/EclipseAtlatlBurnEffect.kt @@ -0,0 +1,49 @@ +package org.rsmod.api.mechanics.toxins + +import jakarta.inject.Inject +import org.rsmod.api.player.hat +import org.rsmod.api.player.legs +import org.rsmod.api.player.righthand +import org.rsmod.api.player.torso +import org.rsmod.api.player.worn.EquipmentChecks +import org.rsmod.api.random.GameRandom +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Eclipse moon armour's set effect: wiki-verified 20% chance for a successful Eclipse atlatl hit + * to inflict a stack of Burn on the target, while the full set (helm/chestplate/tassets/atlatl) + * is worn. This is what feeds the Eclipse special's own burn-consumption bonus - without this, the + * special's "consume remaining burn damage" mechanic never has anything to consume from a solo + * player's own attacks (it can still trigger off a teammate's Burning claws/Arkan blade/Scorching + * bow burn, per the wiki, but that's not a normal solo case). + * + * Deliberately scoped to *normal* attacks only, matching the special attack's own design: the + * special consumes burn rather than re-applying it, so a solo player alternates ordinary attacks + * (building burn via this passive) with occasional specials (cashing it in) - having the special + * also roll this chance would make it self-consuming and pointless. + */ +public class EclipseAtlatlBurnEffect +@Inject +constructor( + private val burns: BurnEffectService, + private val random: GameRandom, +) { + public fun rollOnHit(source: Player, target: PathingEntity, damage: Int) { + if (damage <= 0) return + val wearingFullSet = + EquipmentChecks.isEclipseMoonSet( + helm = source.hat, + top = source.torso, + legs = source.legs, + weapon = source.righthand, + ) + if (!wearingFullSet) return + if (!random.randomBoolean(BURN_CHANCE_DENOMINATOR)) return + burns.apply(source, target) + } + + private companion object { + const val BURN_CHANCE_DENOMINATOR: Int = 5 + } +} diff --git a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NoxiousHalberdVirulence.kt b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NoxiousHalberdVirulence.kt new file mode 100644 index 000000000..748d99c22 --- /dev/null +++ b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NoxiousHalberdVirulence.kt @@ -0,0 +1,41 @@ +package org.rsmod.api.mechanics.toxins + +import org.rsmod.api.attr.AttributeKey +import org.rsmod.game.entity.Player + +/** + * Transient state granted by the noxious halberd's Virulence special attack. + * + * It is intentionally not persisted, and equipment content clears it as soon as the player + * changes their right-hand weapon. + */ +public object NoxiousHalberdVirulence { + private val minimumHit = + AttributeKey( + resetOnDeath = true, + temp = true, + ) + + public fun activate( + player: Player, + damage: Int, + ) { + require(damage > 0) { + "Virulence requires a positive cured poison or venom damage value." + } + player.attr[minimumHit] = damage + } + + /** + * Returns and clears the minimum damage for the next accurate halberd attack. + */ + public fun consume(player: Player): Int? { + val damage = player.attr[minimumHit] ?: return null + player.attr.remove(minimumHit) + return damage + } + + public fun clear(player: Player) { + player.attr.remove(minimumHit) + } +} diff --git a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonEffectService.kt b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonEffectService.kt new file mode 100644 index 000000000..411eb7c86 --- /dev/null +++ b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonEffectService.kt @@ -0,0 +1,131 @@ +package org.rsmod.api.mechanics.toxins + +import dev.openrune.types.HitmarkTypeGroup +import jakarta.inject.Inject +import jakarta.inject.Singleton +import java.util.WeakHashMap +import org.rsmod.api.config.refs.done.hitmark_groups +import org.rsmod.api.mechanics.toxins.impl.PlayerPoison +import org.rsmod.api.mechanics.toxins.impl.PlayerVenom +import org.rsmod.api.npc.hit.modifier.NpcHitModifier +import org.rsmod.api.npc.hit.queueHit +import org.rsmod.api.npc.isValidTarget +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.queue.WorldQueueList + +/** + * NPC-side poison/venom, reusing [PlayerPoison]/[PlayerVenom]'s own damage formulas (severity + * decaying at 1/tick, `damageForSeverity`; venom escalating 6/8/10.../20 every 30 ticks) since + * NPCs otherwise get poisoned/envenomed identically to players. + * + * Lighter-weight than the player version: no varp/status-orb sync (NPCs have no such UI), no + * worn-item or [ToxinImmunity]-style immunity checks, and tracked in-memory + * (`WeakHashMap`, self-rescheduling via [WorldQueueList]) rather than persisted - the + * same documented scope as this session's other timed-effect additions + * ([BurnEffectService][org.rsmod.api.mechanics.toxins.BurnEffectService] is the sibling example). + * Envenoming clears any active poison, matching [PlayerVenom]'s own behavior; poisoning an already + * envenomed target is a no-op. + */ +@Singleton +public class NpcPoisonEffectService +@Inject +constructor( + private val worldQueues: WorldQueueList, + private val npcHitModifier: NpcHitModifier, +) { + private val poisonSeverity = WeakHashMap() + private val venomStrikes = WeakHashMap() + + /** Applies ordinary poison from [initialDamage] worth of damage (matches weapon-poison use). */ + public fun apply(source: Player, target: Npc, initialDamage: Int) { + if (initialDamage <= 0 || venomStrikes.containsKey(target)) { + return + } + val severity = PlayerPoison.severityForInitialDamage(initialDamage) + val current = poisonSeverity[target] ?: 0 + if (!NpcPoisonOverride.shouldApply(initialDamage, severity, current)) { + return + } + // Wiki: poison damages "once every 30 game ticks" as a recurring cycle - nothing hits the + // instant it's applied (matches PlayerVenom.applyVenom's own already-correct behavior). + // This used to fire an immediate hit here too, matching PlayerPoison.applyPoison's own + // pre-fix bug. + val alreadyTicking = current > 0 + poisonSeverity[target] = severity + if (!alreadyTicking) { + schedulePoisonTick(target) + } + } + + public fun applyVenom(source: Player, target: Npc) { + if (venomStrikes.containsKey(target)) { + return + } + poisonSeverity.remove(target) + venomStrikes[target] = 0 + scheduleVenomTick(target) + } + + private fun schedulePoisonTick(target: Npc) { + worldQueues.add(PlayerPoison.TICK_INTERVAL) { + var severity = poisonSeverity[target] ?: return@add + if (severity <= 0 || !target.isValidTarget()) { + poisonSeverity.remove(target) + return@add + } + queueHit(target, PlayerPoison.damageForSeverity(severity), hitmark_groups.poison_damage) + severity-- + if (severity <= 0) { + poisonSeverity.remove(target) + } else { + poisonSeverity[target] = severity + schedulePoisonTick(target) + } + } + } + + private fun scheduleVenomTick(target: Npc) { + worldQueues.add(PlayerVenom.TICK_INTERVAL) { + val strikes = venomStrikes[target] ?: return@add + if (!target.isValidTarget()) { + venomStrikes.remove(target) + return@add + } + queueHit(target, PlayerVenom.damageForStrikeIndex(strikes), hitmark_groups.venom) + venomStrikes[target] = strikes + 1 + scheduleVenomTick(target) + } + } + + private fun queueHit(target: Npc, damage: Int, hitmark: HitmarkTypeGroup) { + target.queueHit( + delay = 1, + type = HitType.Typeless, + damage = damage, + modifier = npcHitModifier, + hitmark = hitmark, + ) + } +} + +/** + * Whether a new poison application should override the currently active one, matching + * [PlayerPoison]'s own override rule: a strictly weaker poison (lower first-hit damage) never + * overrides; an equal-damage one only overrides if its severity (remaining duration) is actually + * higher. + */ +internal object NpcPoisonOverride { + fun shouldApply(newInitialDamage: Int, newSeverity: Int, currentSeverity: Int): Boolean { + val currentDamage = + if (currentSeverity > 0) PlayerPoison.damageForSeverity(currentSeverity) else 0 + if (newInitialDamage < currentDamage) { + return false + } + if (newInitialDamage == currentDamage && newSeverity <= currentSeverity) { + return false + } + return true + } +} diff --git a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecay.kt b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecay.kt new file mode 100644 index 000000000..30aee81a1 --- /dev/null +++ b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecay.kt @@ -0,0 +1,50 @@ +package org.rsmod.api.mechanics.toxins + +import org.rsmod.api.attr.AttributeKey +import org.rsmod.game.entity.Player + +/** + * Holds the transient deadline for the next Soulreaper Axe stack decay. + * + * The normal axe attack resets this deadline. Behead clears it because it consumes every stack. + * This uses the game cycle directly instead of an RSCM queue, since rev 240 does not define a + * Soulreaper-specific queue key. + */ +public object SoulreaperStackDecay { + // Wiki: "one stack decaying every 50 ticks (30s)" if not actively attacking with the axe. + public const val INTERVAL_CYCLES: Int = 50 + + private val dueAt = + AttributeKey( + resetOnDeath = true, + temp = true, + ) + + public fun reset( + player: Player, + clock: Int = player.currentMapClock, + ) { + player.attr[dueAt] = nextDeadline(clock) + } + + public fun isDue( + player: Player, + clock: Int, + ): Boolean { + val due = player.attr[dueAt] ?: return false + return isDue(clock, due) + } + + public fun clear(player: Player) { + player.attr.remove(dueAt) + } + + /** Pure timing math, split out from the [Player]-attached state so it can be unit tested. */ + public fun nextDeadline(clock: Int): Int = clock + INTERVAL_CYCLES + + /** Pure timing math, split out from the [Player]-attached state so it can be unit tested. */ + public fun isDue( + clock: Int, + deadline: Int, + ): Boolean = clock >= deadline +} diff --git a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonEffect.kt b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonEffect.kt new file mode 100644 index 000000000..45ecf61c9 --- /dev/null +++ b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonEffect.kt @@ -0,0 +1,86 @@ +package org.rsmod.api.mechanics.toxins + +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.mechanics.toxins.impl.PlayerPoison +import org.rsmod.api.random.GameRandom +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Weapon poison - (p)/(p+)/(p++) melee weapons (daggers, spears, hastae) and ranged ammo (arrows, + * bolts, darts, javelins, knives) get a chance to poison the target on a successful hit. + * + * Wiki-verified (Weapon poison / Weapon poison(+) / Weapon poison(++)): melee attacks apply + * poison at a 25% chance (1/4), ranged at 12.5% (1/8) - both only on a successful (non-zero + * damage) hit. Initial poison damage is 4/5/6 (melee) or 2/3/4 (ranged) depending on tier - the + * existing [PlayerPoison]/[NpcPoisonEffectService] severity math (unchanged, already fixed to not + * hit instantly) handles everything past that point identically to any other poison source. + * + * Tier is read directly off an RSCM alias suffix ("_p"/"_p+"/"_p++") rather than a new + * `items.toml` param - the real cache already carries this consistently across every poisoned + * melee weapon and ranged ammo variant, so no per-item toml edits are needed to cover them. For + * melee this is the wielded weapon itself; for ranged it's whichever item was actually consumed + * as ammo (quiver ammo for bows/crossbows, or the wielded item itself for thrown weapons like + * darts/knives/javelins) - the tier lives on the ammo, not the bow/crossbow firing it. + */ +public class WeaponPoisonEffect +@Inject +constructor( + private val npcPoison: NpcPoisonEffectService, + private val random: GameRandom, +) { + public fun rollOnMeleeHit( + source: Player, + target: PathingEntity, + weapon: ItemServerType, + damage: Int, + ) { + if (damage <= 0) return + val tier = WeaponPoisonTier.of(weapon.internalName) ?: return + if (!random.randomBoolean(WeaponPoisonTier.MELEE_CHANCE_DENOMINATOR)) return + apply(source, target, tier.meleeDamage) + } + + public fun rollOnRangedHit( + source: Player, + target: PathingEntity, + ammo: ItemServerType?, + damage: Int, + ) { + if (damage <= 0 || ammo == null) return + val tier = WeaponPoisonTier.of(ammo.internalName) ?: return + if (!random.randomBoolean(WeaponPoisonTier.RANGED_CHANCE_DENOMINATOR)) return + apply(source, target, tier.rangedDamage) + } + + private fun apply(source: Player, target: PathingEntity, initialDamage: Int) { + when (target) { + is Npc -> npcPoison.apply(source, target, initialDamage) + is Player -> PlayerPoison.tryPoison(target, initialDamage = initialDamage) + } + } +} + +/** Pure alias-to-tier lookup, kept separate from [WeaponPoisonEffect] so it's testable without DI. */ +internal enum class WeaponPoisonTier(val meleeDamage: Int, val rangedDamage: Int) { + Regular(meleeDamage = 4, rangedDamage = 2), + Plus(meleeDamage = 5, rangedDamage = 3), + PlusPlus(meleeDamage = 6, rangedDamage = 4); + + companion object { + const val MELEE_CHANCE_DENOMINATOR: Int = 4 + const val RANGED_CHANCE_DENOMINATOR: Int = 8 + + fun of(weaponAlias: String?): WeaponPoisonTier? { + if (weaponAlias == null) return null + return when { + weaponAlias.endsWith("_p++") -> PlusPlus + weaponAlias.endsWith("_p+") -> Plus + weaponAlias.endsWith("_p") -> Regular + else -> null + } + } + } +} diff --git a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/impl/PlayerPoison.kt b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/impl/PlayerPoison.kt index cda99e1d0..adcf16e70 100644 --- a/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/impl/PlayerPoison.kt +++ b/api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/impl/PlayerPoison.kt @@ -146,18 +146,15 @@ public object PlayerPoison { if (incomingDamage == currentDamage && storedSeverity <= current) return false } + // Wiki: poison damages "once every 30 game ticks" as a recurring cycle - nothing hits the + // instant it's applied. The only "hits immediately" wording on the wiki is scoped to a + // paused timer resuming when an open interface closes, not to fresh application. This + // used to fire an immediate `queuePoisonHit` here (and decrement severity before the + // first real tick), which isn't how it actually works - just start the timer at the full + // severity and let `onPoisonTimerTick` (already correct) handle the first hit 30 ticks + // from now, same as every subsequent one. VarPlayerIntMapSetter.set(player, "varp.poison_severity", storedSeverity) - val firstHitDamage = - if (initialDamage > 0) initialDamage else damageForSeverity(storedSeverity) - queuePoisonHit(player, firstHitDamage) - - val severity = storedSeverity - 1 - if (severity <= 0) { - clear(player) - } else { - VarPlayerIntMapSetter.set(player, "varp.poison_severity", severity) - player.timer("timer.player_poison", TICK_INTERVAL) - } + player.timer("timer.player_poison", TICK_INTERVAL) player.mes("You have been poisoned!", ChatType.Spam) Toxin.syncStatusOrbs(player) return true diff --git a/api/obj-charges/src/main/kotlin/org/rsmod/api/obj/charges/ObjChargeManager.kt b/api/obj-charges/src/main/kotlin/org/rsmod/api/obj/charges/ObjChargeManager.kt index b7293d552..f99702641 100644 --- a/api/obj-charges/src/main/kotlin/org/rsmod/api/obj/charges/ObjChargeManager.kt +++ b/api/obj-charges/src/main/kotlin/org/rsmod/api/obj/charges/ObjChargeManager.kt @@ -18,7 +18,7 @@ import org.rsmod.utils.bits.withBits public class ObjChargeManager { public fun getCharges(obj: InvObj?, internal: String): Int { - val varobj = ServerCacheManager.getVarObj(internal.asRSCM(RSCMType.VARCON)) ?: error("Unable to find varobj: $internal") + val varobj = ServerCacheManager.getVarObj(internal.asRSCM(RSCMType.VAROBJ)) ?: error("Unable to find varobj: $internal") return obj?.vars?.getBits(varobj.bits) ?: 0 } @@ -47,7 +47,7 @@ public class ObjChargeManager { internal: String, max: Int, ): Charge { - val varobj = ServerCacheManager.getVarObj(internal.asRSCM(RSCMType.VARCON)) ?: error("Unable to find varobj: $internal") + val varobj = ServerCacheManager.getVarObj(internal.asRSCM(RSCMType.VAROBJ)) ?: error("Unable to find varobj: $internal") val chargeRange = 0..varobj.bits.bitMask require(max in chargeRange) { "`max` charges ($max) must be within range [0..${varobj.bits.bitMask}]. (var=$varobj)" @@ -61,17 +61,11 @@ public class ObjChargeManager { val updatedVar = obj.vars.withBits(varobj.bits, total) val added = total - curr - if (curr == 0) { - // If the obj currently has no charges, we assume it is a _valid_ uncharged version - // (i.e., it has the `charged_variant` param defined). If not, we throw an exception. - // While someone with spawn permissions could technically have a "charged" obj with - // 0 charges, we enforce strict correctness here: they should spawn the uncharged - // variant and charge it properly. This helps avoid unintended oversights. - val charged = getInvObj(obj).paramOrNull(params.charged_variant) - if (charged == null) { - val message = "Obj missing `charged_variant` param: $obj (type=${getInvObj(obj)})" - throw IllegalStateException(message) - } + // Whether this obj is the uncharged variant is determined by its own `charged_variant` + // param, not by whether it currently holds 0 charges - an already-charged obj (e.g. a + // Webweaver bow activated with exactly its minimum ether cost) can legitimately hold 0. + val charged = getInvObj(obj).paramOrNull(params.charged_variant) + if (charged != null) { inventory[slot] = InvObj(charged, vars = updatedVar) return Charge.Success.AddChangeObj(added = added, total = total, charged = charged) } @@ -109,7 +103,7 @@ public class ObjChargeManager { throw IllegalStateException(message) } - val varobj = ServerCacheManager.getVarObj(internal.asRSCM(RSCMType.VARCON))?: error("Unable to find varobj: $internal") + val varobj = ServerCacheManager.getVarObj(internal.asRSCM(RSCMType.VAROBJ)) ?: error("Unable to find varobj: $internal") val currentCharges = obj.vars.getBits(varobj.bits) if (currentCharges < decrement) { diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/BypassProtectionPrayerPlayerHitModifier.kt b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/BypassProtectionPrayerPlayerHitModifier.kt new file mode 100644 index 000000000..c74ca3c63 --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/BypassProtectionPrayerPlayerHitModifier.kt @@ -0,0 +1,24 @@ +package org.rsmod.api.player.hit.modifier + +import org.rsmod.api.player.cheat.adminGodMode +import org.rsmod.api.player.hit.PlayerAbsorption +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitBuilder + +/** + * Same as [StandardPlayerHitModifier], but skips the protection-prayer damage reduction step - + * for specials that are documented to ignore Protect from Melee/Missiles/Magic (Ancient mace, + * Dragon sword's Wild Stab). God mode and NPC absorption still apply. + */ +public object BypassProtectionPrayerPlayerHitModifier : PlayerHitModifier { + override fun HitBuilder.modify(target: Player) { + if (target.adminGodMode) { + damage = 0 + return + } + + if (isFromNpc) { + damage = PlayerAbsorption.absorb(player = target, incomingDamage = damage) + } + } +} diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/MorriganHamstring.kt b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/MorriganHamstring.kt new file mode 100644 index 000000000..8a3e7556f --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/MorriganHamstring.kt @@ -0,0 +1,27 @@ +package org.rsmod.api.player.hit.modifier + +import org.rsmod.api.attr.AttributeKey +import org.rsmod.game.entity.Player + +/** + * Morrigan's throwing axe's Hamstring: a landed special-attack hit against a player makes their + * run energy drain 6x faster for one minute (wiki: "For the next minute, your run energy will + * drain 6x faster"). + */ +public object MorriganHamstring { + public const val RUN_ENERGY_DRAIN_MULTIPLIER: Int = 6 + + // 1 minute = 60s / 0.6s per cycle = 100 cycles. + private const val DURATION_CYCLES: Int = 100 + + private val activeUntil = AttributeKey(resetOnDeath = true, temp = true) + + public fun activate(player: Player) { + player.attr[activeUntil] = player.currentMapClock + DURATION_CYCLES + } + + public fun isActive(player: Player): Boolean { + val until = player.attr[activeUntil] ?: return false + return player.currentMapClock < until + } +} diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/PowerOfDeathMeleeProtection.kt b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/PowerOfDeathMeleeProtection.kt new file mode 100644 index 000000000..f0c4cf939 --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/PowerOfDeathMeleeProtection.kt @@ -0,0 +1,44 @@ +package org.rsmod.api.player.hit.modifier + +import org.rsmod.api.attr.AttributeKey +import org.rsmod.api.player.righthand +import org.rsmod.game.entity.Player +import org.rsmod.game.inv.isAnyType + +/** + * Power of Death (Staff of the Dead family): halves incoming melee damage for one minute after + * activating, but only while a Staff of the Dead family weapon remains equipped - it is lost + * immediately (not just on a timer) if the staff is swapped out, matching the wiki: "the effect + * lasts for one minute... but will instantly end if the player takes damage while the staff is + * not equipped." + */ +public object PowerOfDeathMeleeProtection { + // 1 minute = 60s / 0.6s per cycle = 100 cycles. + private const val DURATION_CYCLES: Int = 100 + + private val activeUntil = AttributeKey(resetOnDeath = true, temp = true) + + public fun activate(player: Player) { + player.attr[activeUntil] = player.currentMapClock + DURATION_CYCLES + } + + public fun isActive(player: Player): Boolean { + val until = player.attr[activeUntil] ?: return false + if (player.currentMapClock >= until) { + return false + } + return player.isWearingStaffOfTheDeadFamily() + } + + private fun Player.isWearingStaffOfTheDeadFamily(): Boolean = + righthand?.isAnyType( + "obj.sotd", + "obj.br_sotd", + "obj.staff_of_light", + "obj.staff_of_balance", + "obj.toxic_sotd", + "obj.toxic_sotd_charged", + "obj.toxic_sotd_deadman", + "obj.toxic_sotd_charged_deadman", + ) == true +} diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/StandardPlayerHitModifier.kt b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/StandardPlayerHitModifier.kt index b4577a82d..3d5c5df37 100644 --- a/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/StandardPlayerHitModifier.kt +++ b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/StandardPlayerHitModifier.kt @@ -13,6 +13,14 @@ public object StandardPlayerHitModifier : PlayerHitModifier { return } + if ( + (type == HitType.Melee || type == HitType.Ranged) && + VestaSpearCombatImmunity.isActive(target) + ) { + damage = 0 + return + } + val protectionPrayer = when (type) { HitType.Typeless -> false @@ -26,6 +34,10 @@ public object StandardPlayerHitModifier : PlayerHitModifier { damage = (damage * (100 - reduction)) / 100 } + if (type == HitType.Melee && PowerOfDeathMeleeProtection.isActive(target)) { + damage /= 2 + } + if (isFromNpc) { damage = PlayerAbsorption.absorb( diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/VestaSpearCombatImmunity.kt b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/VestaSpearCombatImmunity.kt new file mode 100644 index 000000000..770307907 --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/VestaSpearCombatImmunity.kt @@ -0,0 +1,26 @@ +package org.rsmod.api.player.hit.modifier + +import org.rsmod.api.attr.AttributeKey +import org.rsmod.game.entity.Player + +/** + * Vesta's spear's Spear Wall: full immunity to melee and ranged damage for 8 game cycles after + * activating. Per the current wiki page, both melee and ranged are blocked ("the user becomes + * immune to melee and ranged attacks for 8 ticks") - ranged immunity was added in a later update + * after the effect originally covered melee only. Unlike Power of Death, this doesn't require the + * weapon to remain equipped once active. + */ +public object VestaSpearCombatImmunity { + private const val DURATION_CYCLES: Int = 8 + + private val activeUntil = AttributeKey(resetOnDeath = true, temp = true) + + public fun activate(player: Player) { + player.attr[activeUntil] = player.currentMapClock + DURATION_CYCLES + } + + public fun isActive(player: Player): Boolean { + val until = player.attr[activeUntil] ?: return false + return player.currentMapClock < until + } +} diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/prayer/ProtectionPrayerLockout.kt b/api/player/src/main/kotlin/org/rsmod/api/player/prayer/ProtectionPrayerLockout.kt new file mode 100644 index 000000000..a0add861f --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/prayer/ProtectionPrayerLockout.kt @@ -0,0 +1,46 @@ +package org.rsmod.api.player.prayer + +import org.rsmod.api.attr.AttributeKey +import org.rsmod.api.player.vars.VarPlayerIntMapSetter +import org.rsmod.game.entity.Player + +/** + * Tracks a temporary block on activating protection prayers (Dragon scimitar's Sever, in PvP). + * The three protection-prayer varbits, shared with [org.rsmod.api.player.hit.modifier + * .StandardPlayerHitModifier]'s own protection-prayer check. + */ +public object ProtectionPrayerLockout { + // Wiki: "prevents the targeted player from using said prayers for 8 ticks (4.8 seconds)". + private const val LOCKOUT_CYCLES: Int = 8 + + public val PROTECTION_PRAYER_VARBITS: Set = + setOf( + "varbit.prayer_protectfrommelee", + "varbit.prayer_protectfrommissiles", + "varbit.prayer_protectfrommagic", + ) + + private val lockedUntil = AttributeKey(resetOnDeath = true, temp = true) + + public fun activate(player: Player) { + player.attr[lockedUntil] = player.currentMapClock + LOCKOUT_CYCLES + } + + public fun isLocked(player: Player): Boolean { + val until = player.attr[lockedUntil] ?: return false + return player.currentMapClock < until + } +} + +/** + * Immediately turns off any active protection prayer and blocks re-activating one for 8 ticks. + * PvP-only per the wiki - callers are expected to only invoke this against [Player] targets. + */ +public fun Player.disableProtectionPrayers() { + for (varbit in ProtectionPrayerLockout.PROTECTION_PRAYER_VARBITS) { + if (vars[varbit] != 0) { + VarPlayerIntMapSetter.set(this, varbit, 0) + } + } + ProtectionPrayerLockout.activate(this) +} diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/ranged/BlowpipeAmmo.kt b/api/player/src/main/kotlin/org/rsmod/api/player/ranged/BlowpipeAmmo.kt new file mode 100644 index 000000000..e0cf47ca7 --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/ranged/BlowpipeAmmo.kt @@ -0,0 +1,394 @@ +package org.rsmod.api.player.ranged + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.righthand +import org.rsmod.game.entity.Player +import org.rsmod.game.inv.InvObj +import org.rsmod.game.inv.Inventory +import org.rsmod.utils.bits.getBits +import org.rsmod.utils.bits.withBits + +/** + * Accesses the cache-packed ammunition stored inside toxic and rosewood blowpipes. + * + * Both blowpipe families store their dart type and count in the same varobj bit fields rather than + * in the quiver. Toxic blowpipes additionally store their scales in that packed value. + */ +public object BlowpipeAmmo { + /** A resolved dart type together with the cache ordinal stored in the equipped blowpipe. */ + public data class LoadedDart(val type: ItemServerType, val index: Int) + + /** The remaining ammunition after a successful stored-ammo reduction. */ + public data class Consumption( + val dartsLeft: Int, + val scalesLeft: Int, + val becameEmpty: Boolean, + val canFire: Boolean, + ) + + /** The exact supplies recovered from one specific blowpipe by the unload interaction. */ + public data class UnloadedContents( + val dart: LoadedDart?, + val dartCount: Int, + val scaleCount: Int, + ) + + /** The exact darts recovered by the normal Unload interaction. */ + public data class UnloadedDarts(val dart: LoadedDart?, val count: Int) + + /** Returns the dart currently loaded in [obj], or null when it has no usable stored dart. */ + public fun loadedDart(obj: InvObj?): LoadedDart? { + val blowpipe = obj ?: return null + val variant = variant(blowpipe) ?: return null + if (!variant.loaded) { + return null + } + + return storedDart(blowpipe) + } + + /** Returns the stored dart even when a depleted pipe has changed to its empty item variant. */ + public fun storedDart(obj: InvObj?): LoadedDart? { + val blowpipe = obj ?: return null + if (variant(blowpipe) == null) { + return null + } + val dartCount = darts(blowpipe) + if (dartCount <= 0) { + return null + } + + val index = blowpipe.vars.getBits(dartType.bits) + val type = dartTypes.getOrNull(index) ?: return null + return LoadedDart(type = type, index = index) + } + + /** + * Returns whether the stored dart is valid for this blowpipe family. + * + * Toxic blowpipes accept the full dart range through dragon; rosewood blowpipes stop at + * rune (wiki: "It is able to shoot up to rune darts"). + */ + public fun canUseLoadedDart(obj: InvObj?): Boolean { + val blowpipe = obj ?: return false + val variant = variant(blowpipe) ?: return false + val dart = loadedDart(blowpipe) ?: return false + return dart.index <= variant.family.maxDartIndex + } + + /** Every revision-240 toxic-blowpipe variant that stores its own scales and darts. */ + public fun toxicBlowpipeAliases(): List = + variants.filter { it.family.usesScales }.map { it.alias } + + /** Charged-cache variants which expose Wield, Check, Unload, and Uncharge options. */ + public fun toxicLoadedBlowpipeAliases(): List = + variants.filter { it.family.usesScales && it.loaded }.map { it.alias } + + /** All cache-recognised dart aliases that can be inserted into a toxic blowpipe. */ + public fun toxicDartAliases(): List = dartAliases + + /** Returns whether [obj] is one of the toxic (scale-consuming) blowpipe variants. */ + public fun isToxic(obj: InvObj?): Boolean = variant(obj)?.family?.usesScales == true + + /** + * Stores up to [requested] copies of [dart] in the exact blowpipe at [slot]. + * + * A pipe may only contain one dart type. Callers delete exactly the returned amount from the + * inventory, so hitting capacity never deletes excess darts. + */ + public fun storeDarts( + inventory: Inventory, + slot: Int, + dart: ItemServerType, + requested: Int, + ): Int { + require(requested >= 0) { "requested must not be negative." } + val obj = inventory[slot] ?: return 0 + val variant = variant(obj) ?: return 0 + val dartIndex = dartTypes.indexOfFirst { it.id == dart.id } + if (dartIndex !in 0..variant.family.maxDartIndex) { + return 0 + } + + val loaded = storedDart(obj) + if (loaded != null && loaded.type.id != dart.id) { + return 0 + } + + val current = darts(obj) + val total = (current + requested).coerceAtMost(MAX_STORED_AMOUNT) + val added = total - current + if (added == 0) { + return 0 + } + + val packed = obj.vars.withBits(dartType.bits, dartIndex).withBits(dartCount.bits, total) + inventory[slot] = InvObj(item(variant.loadedAlias), count = obj.count, vars = packed) + return added + } + + /** Stores up to [requested] Zulrah's scales in the exact toxic blowpipe at [slot]. */ + public fun storeScales(inventory: Inventory, slot: Int, requested: Int): Int { + require(requested >= 0) { "requested must not be negative." } + val obj = inventory[slot] ?: return 0 + val variant = variant(obj) ?: return 0 + if (!variant.family.usesScales) { + return 0 + } + + val current = scales(obj) + val total = (current + requested).coerceAtMost(MAX_STORED_AMOUNT) + val added = total - current + if (added == 0) { + return 0 + } + + val packed = obj.vars.withBits(scaleCount.bits, total) + inventory[slot] = InvObj(item(variant.loadedAlias), count = obj.count, vars = packed) + return added + } + + /** Removes only the stored darts, preserving every scale in this specific pipe. */ + public fun unloadDarts(inventory: Inventory, slot: Int): UnloadedDarts? { + val obj = inventory[slot] ?: return null + val variant = variant(obj) ?: return null + val count = darts(obj) + val dart = storedDart(obj) + val packed = obj.vars.withBits(dartType.bits, 0).withBits(dartCount.bits, 0) + val keepLoaded = variant.family.usesScales && scales(obj) > 0 + val alias = if (keepLoaded) variant.loadedAlias else variant.emptyAlias + inventory[slot] = InvObj(item(alias), count = obj.count, vars = packed) + return UnloadedDarts(dart = dart, count = count) + } + + /** + * Clears the packed charges on the pipe at [slot] and returns the exact contents for the caller + * to restore to the inventory after checking capacity. + */ + public fun uncharge(inventory: Inventory, slot: Int): UnloadedContents? { + val obj = inventory[slot] ?: return null + val variant = variant(obj) ?: return null + val dartCount = darts(obj) + val dart = storedDart(obj) + val scaleCount = scales(obj) + val packed = + obj.vars + .withBits(dartType.bits, 0) + .withBits(this.dartCount.bits, 0) + .withBits(this.scaleCount.bits, 0) + inventory[slot] = InvObj(item(variant.emptyAlias), count = obj.count, vars = packed) + return UnloadedContents(dart = dart, dartCount = dartCount, scaleCount = scaleCount) + } + + /** Returns the stored dart count, or zero when [obj] is not a tracked blowpipe. */ + public fun darts(obj: InvObj?): Int { + val blowpipe = obj ?: return 0 + if (variant(blowpipe) == null) { + return 0 + } + return blowpipe.vars.getBits(dartCount.bits) + } + + /** Returns the stored toxic-scale count, or zero for non-toxic blowpipes. */ + public fun scales(obj: InvObj?): Int { + val blowpipe = obj ?: return 0 + val variant = variant(blowpipe) ?: return 0 + if (!variant.family.usesScales) { + return 0 + } + return blowpipe.vars.getBits(scaleCount.bits) + } + + /** Returns whether [obj] has at least [amount] stored darts. */ + public fun hasDarts(obj: InvObj?, amount: Int): Boolean { + require(amount >= 0) { "amount must not be negative." } + return variant(obj) != null && darts(obj) >= amount + } + + /** Returns whether [obj] has at least [amount] stored toxic scales. */ + public fun hasScales(obj: InvObj?, amount: Int): Boolean { + require(amount >= 0) { "amount must not be negative." } + return scales(obj) >= amount + } + + /** + * Removes [darts] and [scales] from the equipped blowpipe in one packed-value update. + * + * A toxic blowpipe remains its loaded cache item while it still contains either darts or + * scales. This keeps Check/Unload/Uncharge available when one supply reaches zero. It changes + * to the empty cache item only when both supplies are gone. Null means the equipped item did + * not have enough matching stored ammunition. + */ + public fun consume(player: Player, darts: Int, scales: Int = 0): Consumption? { + require(darts >= 0) { "darts must not be negative." } + require(scales >= 0) { "scales must not be negative." } + + val obj = player.righthand ?: return null + val variant = variant(obj) ?: return null + if (!variant.loaded || (!variant.family.usesScales && scales != 0)) { + return null + } + + val currentDarts = this.darts(obj) + val currentScales = this.scales(obj) + if (currentDarts < darts || currentScales < scales) { + return null + } + + val consumption = + resolveConsumption( + currentDarts, + currentScales, + darts, + scales, + variant.family.usesScales, + ) ?: return null + if (darts == 0 && scales == 0) { + return consumption + } + + var packed = obj.vars.withBits(dartCount.bits, consumption.dartsLeft) + if (variant.family.usesScales) { + packed = packed.withBits(scaleCount.bits, consumption.scalesLeft) + } + + player.righthand = + if (consumption.becameEmpty) { + InvObj(item(variant.emptyAlias), count = obj.count, vars = packed) + } else { + obj.copy(vars = packed) + } + return consumption + } + + internal fun resolveConsumption( + currentDarts: Int, + currentScales: Int, + darts: Int, + scales: Int, + usesScales: Boolean, + ): Consumption? { + if (darts < 0 || scales < 0 || currentDarts < darts || currentScales < scales) { + return null + } + val dartsLeft = currentDarts - darts + val scalesLeft = currentScales - scales + val becameEmpty = dartsLeft == 0 && (!usesScales || scalesLeft == 0) + val canFire = dartsLeft > 0 && (!usesScales || scalesLeft > 0) + return Consumption(dartsLeft, scalesLeft, becameEmpty, canFire) + } + + /** Returns the stored dart strength contribution for the equipment-bonus calculation. */ + public fun rangedStrengthBonus(obj: InvObj?): Int { + val dart = loadedDart(obj) ?: return 0 + return if (canUseLoadedDart(obj)) { + dart.type.param(params.ranged_strength) + } else { + 0 + } + } + + private fun variant(obj: InvObj?): BlowpipeVariant? = obj?.let { variantsById[it.id] } + + private fun item(alias: String): ItemServerType = + ServerCacheManager.getItem(alias.asRSCM(RSCMType.OBJ)) + ?: error("Missing cache item: $alias") + + private fun varobj(alias: String) = + ServerCacheManager.getVarObj(alias.asRSCM(RSCMType.VAROBJ)) + ?: error("Missing cache varobj: $alias") + + private enum class BlowpipeFamily(val maxDartIndex: Int, val usesScales: Boolean) { + Toxic(maxDartIndex = DRAGON_DART_INDEX, usesScales = true), + + // Wiki: "It is able to shoot up to rune darts" - torka's original had this capped at + // adamant, which was wrong (or predates a later expansion of the cap on the live wiki). + Rosewood(maxDartIndex = RUNE_DART_INDEX, usesScales = false), + } + + private data class BlowpipeVariant( + val alias: String, + val loadedAlias: String, + val emptyAlias: String, + val family: BlowpipeFamily, + val loaded: Boolean, + ) + + private val variantsById: Map by lazy { + variants.associateBy { it.alias.asRSCM(RSCMType.OBJ) } + } + + private val dartTypes: List by lazy { dartAliases.map(::item) } + + private val dartType by lazy { varobj("varobj.snakeboss_blowpipe_darttype") } + private val dartCount by lazy { varobj("varobj.snakeboss_blowpipe_dartcount") } + private val scaleCount by lazy { varobj("varobj.snakeboss_blowpipe_flakes") } + + private val variants = + listOf( + BlowpipeVariant( + alias = "obj.toxic_blowpipe_loaded", + loadedAlias = "obj.toxic_blowpipe_loaded", + emptyAlias = "obj.toxic_blowpipe", + family = BlowpipeFamily.Toxic, + loaded = true, + ), + BlowpipeVariant( + alias = "obj.toxic_blowpipe_loaded_ornament", + loadedAlias = "obj.toxic_blowpipe_loaded_ornament", + emptyAlias = "obj.toxic_blowpipe_ornament", + family = BlowpipeFamily.Toxic, + loaded = true, + ), + BlowpipeVariant( + alias = "obj.rosewood_blowpipe", + loadedAlias = "obj.rosewood_blowpipe", + emptyAlias = "obj.rosewood_blowpipe_empty", + family = BlowpipeFamily.Rosewood, + loaded = true, + ), + BlowpipeVariant( + alias = "obj.toxic_blowpipe", + loadedAlias = "obj.toxic_blowpipe_loaded", + emptyAlias = "obj.toxic_blowpipe", + family = BlowpipeFamily.Toxic, + loaded = false, + ), + BlowpipeVariant( + alias = "obj.toxic_blowpipe_ornament", + loadedAlias = "obj.toxic_blowpipe_loaded_ornament", + emptyAlias = "obj.toxic_blowpipe_ornament", + family = BlowpipeFamily.Toxic, + loaded = false, + ), + BlowpipeVariant( + alias = "obj.rosewood_blowpipe_empty", + loadedAlias = "obj.rosewood_blowpipe", + emptyAlias = "obj.rosewood_blowpipe_empty", + family = BlowpipeFamily.Rosewood, + loaded = false, + ), + ) + + private val dartAliases = + listOf( + "obj.bronze_dart", + "obj.iron_dart", + "obj.steel_dart", + "obj.black_dart", + "obj.mithril_dart", + "obj.adamant_dart", + "obj.rune_dart", + "obj.amethyst_dart", + "obj.dragon_dart", + ) + + private const val MAX_STORED_AMOUNT: Int = 16_383 + + private const val RUNE_DART_INDEX: Int = 6 + private const val DRAGON_DART_INDEX: Int = 8 +} diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/stat/PlayerStatExtensions.kt b/api/player/src/main/kotlin/org/rsmod/api/player/stat/PlayerStatExtensions.kt index fc0686058..2246e100d 100644 --- a/api/player/src/main/kotlin/org/rsmod/api/player/stat/PlayerStatExtensions.kt +++ b/api/player/src/main/kotlin/org/rsmod/api/player/stat/PlayerStatExtensions.kt @@ -101,7 +101,7 @@ public fun Player.statAdd(stat: String, constant: Int, percent: Int) { statMap.setCurrentLevel(stat, cappedLevel.toByte()) - val statType = ServerCacheManager.getStats(stat.asRSCM(RSCMType.STAT))?: error("No stat found for $stat") + val statType = ServerCacheManager.getStats(stat.asRSCM(RSCMType.STAT)) ?: error("No stat found for $stat") updateStat(stat) @@ -247,7 +247,13 @@ public fun Player.statHeal(internal: String, constant: Int, percent: Int) { val base = statBase(internal) val current = stat(internal) val calculated = current + (constant + (base * percent) / 100) - val cappedLevel = calculated.coerceIn(current, base) + // `current` can already sit above `base` (an earlier heal/boost overshot it), and + // `coerceIn(current, base)` requires its lower bound <= its upper bound - crashed with + // "Cannot coerce value to an empty range" whenever that happened (e.g. Dogsword's Power of the + // Gods healing a player already boosted past their base level). `maxOf(current, base)` keeps + // the normal base-level cap in the common case, and simply holds at the already-boosted + // `current` (no further gain, no crash) once that's higher than base. + val cappedLevel = calculated.coerceIn(current, maxOf(current, base)) statMap.setCurrentLevel(internal, cappedLevel.toByte()) diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/ui/PlayerInterfaceExtensions.kt b/api/player/src/main/kotlin/org/rsmod/api/player/ui/PlayerInterfaceExtensions.kt index 6ef8d2e6e..b30b67e9f 100644 --- a/api/player/src/main/kotlin/org/rsmod/api/player/ui/PlayerInterfaceExtensions.kt +++ b/api/player/src/main/kotlin/org/rsmod/api/player/ui/PlayerInterfaceExtensions.kt @@ -6,8 +6,6 @@ import dev.openrune.definition.type.widget.ComponentType import dev.openrune.definition.type.widget.IfEvent import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType -import dev.openrune.types.ItemServerType -import dev.openrune.types.NpcServerType import dev.openrune.types.SequenceServerType import dev.openrune.types.aconverted.interf.IfSubType import java.awt.Color @@ -306,6 +304,8 @@ private fun Player.openModal(interf: String, internal: String, eventBus: EventBu } public fun Player.setColour(component: String, colour: Color) { + // IfSetColour is 5 bits per channel; the (Int, Color) overload does the 0-255 -> 0-31 + // conversion correctly, the raw (Int, Int, Int, Int) one does not. client.write(IfSetColour(component.asRSCM(), colour)) } diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/worn/EquipmentChecks.kt b/api/player/src/main/kotlin/org/rsmod/api/player/worn/EquipmentChecks.kt index 840586b6b..95b2696ad 100644 --- a/api/player/src/main/kotlin/org/rsmod/api/player/worn/EquipmentChecks.kt +++ b/api/player/src/main/kotlin/org/rsmod/api/player/worn/EquipmentChecks.kt @@ -14,6 +14,19 @@ public object EquipmentChecks { public fun isTwistedBow(obj: InvObj?): Boolean = obj.isType("obj.twisted_bow") + public fun isEclipseAtlatl(obj: InvObj?): Boolean = + obj.isAnyType("obj.eclipse_atlatl", "obj.br_eclipse_atlatl") + + public fun isSerpentineHelm(obj: InvObj?): Boolean = + obj.isAnyType( + "obj.serpentine_helm", + "obj.serpentine_helm_charged", + "obj.serpentine_helm_charged_cyan", + "obj.serpentine_helm_charged_red", + "obj.serpentine_helm_cyan", + "obj.serpentine_helm_red", + ) + public fun isDragonHunterCrossbow(obj: InvObj?): Boolean = obj.isAnyType( "obj.dragonhunter_xbow", @@ -143,6 +156,24 @@ public object EquipmentChecks { "obj.league_3_void_knight_gloves_trouver", ) + public fun isEclipseMoonSet(helm: InvObj?, top: InvObj?, legs: InvObj?, weapon: InvObj?): Boolean = + helm.isAnyType( + "obj.eclipse_moon_helm", + "obj.eclipse_moon_helm_degraded", + "obj.br_eclipse_moon_helm", + ) && + top.isAnyType( + "obj.eclipse_moon_chestplate", + "obj.eclipse_moon_chestplate_degraded", + "obj.br_eclipse_moon_chestplate", + ) && + legs.isAnyType( + "obj.eclipse_moon_tassets", + "obj.eclipse_moon_tassets_degraded", + "obj.br_eclipse_moon_tassets", + ) && + isEclipseAtlatl(weapon) + public fun isDharokSet(helm: InvObj?, top: InvObj?, legs: InvObj?, weapon: InvObj?): Boolean = helm.isAnyType( "obj.barrows_dharok_head_100", diff --git a/api/player/src/main/kotlin/org/rsmod/api/player/worn/HeldEquipOp.kt b/api/player/src/main/kotlin/org/rsmod/api/player/worn/HeldEquipOp.kt index d77558ec4..75c50262f 100644 --- a/api/player/src/main/kotlin/org/rsmod/api/player/worn/HeldEquipOp.kt +++ b/api/player/src/main/kotlin/org/rsmod/api/player/worn/HeldEquipOp.kt @@ -1,5 +1,6 @@ package org.rsmod.api.player.worn +import com.github.michaelbull.logging.InlineLogger import dev.openrune.ServerCacheManager import dev.openrune.rscm.RSCM import dev.openrune.rscm.RSCMType @@ -24,6 +25,8 @@ import org.rsmod.objtx.TransactionResult import org.rsmod.objtx.isErr public class HeldEquipOp @Inject constructor(private val eventBus: EventBus) { + private val logger = InlineLogger() + public fun equip(player: Player, invSlot: Int, inventory: Inventory): HeldEquipResult { val obj = inventory[invSlot] ?: return HeldEquipResult.Fail.InvalidObj val objType = getInvObj(obj) @@ -71,9 +74,13 @@ public class HeldEquipOp @Inject constructor(private val eventBus: EventBus) { val equipTransaction = transaction[0] if (equipTransaction.isErr()) { - check(equipTransaction is TransactionResult.NotEnoughSpace) { - "Transaction error is expected to only be of " + - "`NotEnoughSpace` type: found=$equipTransaction" + if (equipTransaction !is TransactionResult.NotEnoughSpace) { + // An uncaught exception here disconnects the player - fail cleanly instead. + logger.error { + "Unexpected equip transaction error (expected NotEnoughSpace): " + + "found=$equipTransaction, obj=$objType" + } + return HeldEquipResult.Fail.InvalidObj } val message = "You don't have enough free space to do that." return HeldEquipResult.Fail.NotEnoughWornSpace(message) @@ -81,9 +88,12 @@ public class HeldEquipOp @Inject constructor(private val eventBus: EventBus) { val unequipTransactionErr = transaction.err if (unequipTransactionErr != null) { - check(unequipTransactionErr is TransactionResult.NotEnoughSpace) { - "Transaction error is expected to only be of " + - "`NotEnoughSpace` type: found=$unequipTransactionErr" + if (unequipTransactionErr !is TransactionResult.NotEnoughSpace) { + logger.error { + "Unexpected unequip transaction error (expected NotEnoughSpace): " + + "found=$unequipTransactionErr, obj=$objType" + } + return HeldEquipResult.Fail.InvalidObj } val message = "You don't have enough free inventory space to do that." return HeldEquipResult.Fail.NotEnoughInvSpace(message) @@ -114,7 +124,7 @@ public class HeldEquipOp @Inject constructor(private val eventBus: EventBus) { private fun equip(player: Player, type: ItemServerType): HeldEquipResult { val statRequirements = - type.statRequirements().filter { player.statBase(RSCM.getReverseMapping(RSCMType.STAT,it.stat.id)) < it.level } + type.statRequirements().filter { player.statBase(RSCM.getReverseMapping(RSCMType.STAT, it.stat.id)) < it.level } if (statRequirements.isNotEmpty()) { val messages = type.toMessages(statRequirements) return HeldEquipResult.Fail.StatRequirements(messages) diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/NextCycleRangedSpecialTiming.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/NextCycleRangedSpecialTiming.kt new file mode 100644 index 000000000..a27ce8006 --- /dev/null +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/NextCycleRangedSpecialTiming.kt @@ -0,0 +1,61 @@ +package org.rsmod.api.specials + +import java.util.Collections +import java.util.WeakHashMap +import org.rsmod.game.entity.Player + +/** + * Coordinates ranged specials that are armed now but must execute on the following game cycle, even + * if the player is otherwise attack-delayed. + * + * A pending entry is consumed only on its exact due cycle. Cancelling the special, switching the + * weapon, or reaching the combat handler too late leaves normal attack-delay handling intact. + */ +public object NextCycleRangedSpecialTiming { + private val pending: MutableMap = + Collections.synchronizedMap(WeakHashMap()) + + /** Arms [weaponId]'s next-cycle timing from the player's current map clock. */ + public fun schedule(player: Player, weaponId: Int) { + pending[player] = PendingSpecial(weaponId = weaponId, dueCycle = player.currentMapClock + 1) + } + + /** Clears any armed next-cycle special timing for [player]. */ + public fun cancel(player: Player) { + pending.remove(player) + } + + /** + * Resolves the timing state for the current ranged combat pass. + * + * [Resolution.Wait] defers a normal-ready attack until the following cycle. On that cycle, + * [Resolution.BypassAttackDelay] permits this one special attack through the normal guard. + */ + public fun resolve(player: Player, weaponId: Int, specialSelected: Boolean): Resolution = + synchronized(pending) { + val scheduled = pending[player] ?: return@synchronized Resolution.None + if ( + !specialSelected || + scheduled.weaponId != weaponId || + player.currentMapClock > scheduled.dueCycle + ) { + pending.remove(player) + return@synchronized Resolution.None + } + + if (player.currentMapClock < scheduled.dueCycle) { + return@synchronized Resolution.Wait + } + + pending.remove(player) + Resolution.BypassAttackDelay + } + + public enum class Resolution { + None, + Wait, + BypassAttackDelay, + } + + private data class PendingSpecial(val weaponId: Int, val dueCycle: Int) +} diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackManager.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackManager.kt index fccf7fd01..7246db602 100644 --- a/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackManager.kt +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackManager.kt @@ -3,17 +3,17 @@ package org.rsmod.api.specials import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType import dev.openrune.types.ItemServerType -import dev.openrune.types.ProjAnimType -import dev.openrune.types.aconverted.SpotanimType -import dev.openrune.types.aconverted.SynthType import jakarta.inject.Inject import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.magic.Spellbook import org.rsmod.api.combat.commons.styles.MagicAttackStyle import org.rsmod.api.combat.commons.styles.MeleeAttackStyle import org.rsmod.api.combat.commons.styles.RangedAttackStyle import org.rsmod.api.combat.commons.types.MeleeAttackType import org.rsmod.api.combat.commons.types.RangedAttackType import org.rsmod.api.combat.manager.PlayerAttackManager +import org.rsmod.api.player.hit.modifier.PlayerHitModifier +import org.rsmod.api.player.hit.modifier.StandardPlayerHitModifier import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.specials.weapon.SpecialAttackWeapons @@ -38,6 +38,13 @@ constructor( energy.takeSpecialEnergy(source.player, energyInHundreds) } + /** @see [SpecialAttackEnergy.getSpecialEnergy] */ + public fun getSpecialEnergy(source: ProtectedAccess): Int = energy.getSpecialEnergy(source.player) + + /** @see [SpecialAttackEnergy.drainAllSpecialEnergy] */ + public fun drainAllSpecialEnergy(source: ProtectedAccess): Int = + energy.drainAllSpecialEnergy(source.player) + public fun getSpecialEnergyRequirement(obj: String): Int? = weapons.getSpecialEnergy(obj.asRSCM(RSCMType.OBJ)) @@ -139,6 +146,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, multiplier: Double, + defenceMultiplier: Double = 1.0, ): Boolean = manager.rollMeleeAccuracy( source = source.player, @@ -147,6 +155,39 @@ constructor( attackStyle = attackStyle, blockType = blockType, multiplier = multiplier, + defenceMultiplier = defenceMultiplier, + ) + + /** @see [PlayerAttackManager.rollMagicalMeleeAccuracy] */ + public fun rollMagicalMeleeAccuracy( + source: ProtectedAccess, + target: PathingEntity, + attackType: MeleeAttackType?, + attackStyle: MeleeAttackStyle?, + multiplier: Double, + ): Boolean = + manager.rollMagicalMeleeAccuracy( + source = source.player, + target = target, + attackType = attackType, + attackStyle = attackStyle, + multiplier = multiplier, + ) + + /** @see [PlayerAttackManager.rollMagicalRangedAccuracy] */ + public fun rollMagicalRangedAccuracy( + source: ProtectedAccess, + target: PathingEntity, + attackType: RangedAttackType?, + attackStyle: RangedAttackStyle?, + multiplier: Double, + ): Boolean = + manager.rollMagicalRangedAccuracy( + source = source.player, + target = target, + attackType = attackType, + attackStyle = attackStyle, + multiplier = multiplier, ) /** @see [PlayerAttackManager.rollMeleeMaxHit] */ @@ -174,7 +215,8 @@ constructor( target: PathingEntity, damage: Int, delay: Int = 1, - ): Hit = manager.queueMeleeHit(source.player, target, damage, delay) + modifier: PlayerHitModifier = StandardPlayerHitModifier, + ): Hit = manager.queueMeleeHit(source.player, target, damage, delay, modifier) /** @see [PlayerAttackManager.rollRangedDamage] */ public fun rollRangedDamage( @@ -330,6 +372,26 @@ constructor( multiplier = multiplier, ) + /** @see [PlayerAttackManager.rollSpellMaxHit] */ + public fun rollSpellMaxHit( + source: ProtectedAccess, + target: PathingEntity, + spell: ItemServerType, + spellbook: Spellbook?, + baseMaxHit: Int, + attackRate: Int, + sunfireRune: Boolean = false, + ): Int = + manager.rollSpellMaxHit( + source = source.player, + target = target, + spell = spell, + spellbook = spellbook, + baseMaxHit = baseMaxHit, + attackRate = attackRate, + sunfireRune = sunfireRune, + ) + /** @see [PlayerAttackManager.queueMagicHit] */ public fun queueMagicHit( source: ProtectedAccess, diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRegistry.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRegistry.kt index 01955455c..67852e945 100644 --- a/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRegistry.kt +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRegistry.kt @@ -2,11 +2,11 @@ package org.rsmod.api.specials import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType -import dev.openrune.types.ItemServerType import jakarta.inject.Inject import org.rsmod.api.specials.combat.MagicSpecialAttack import org.rsmod.api.specials.combat.MeleeSpecialAttack import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.specials.instant.InstantSpecialAttack import org.rsmod.api.specials.weapon.SpecialAttackWeapons import org.rsmod.game.inv.InvObj @@ -16,6 +16,12 @@ public class SpecialAttackRegistry @Inject constructor(private val weapons: Spec public operator fun get(obj: InvObj): SpecialAttack? = specials[obj.id] + /** The authoritative eligibility check for the live `::specialbank` weapon collection. */ + public fun isRegisteredItem(id: Int): Boolean = id in specials + + /** Returns a snapshot of every item ID whose special is currently registered. */ + public fun registeredItemIds(): Set = specials.keys.toSet() + public fun add(obj: String, spec: InstantSpecialAttack): Result.Add { val id = obj.asRSCM(RSCMType.OBJ) @@ -40,6 +46,16 @@ public class SpecialAttackRegistry @Inject constructor(private val weapons: Spec return Result.Add.Success } + /** Registers a normal-energy melee special for a cache-native item without an RSCM alias. */ + public fun add(obj: Int, energyInHundreds: Int, spec: MeleeSpecialAttack): Result.Add { + require(energyInHundreds in 10..SpecialAttackEnergy.MAX_ENERGY) + if (obj in specials) { + return Result.Add.AlreadyAdded + } + specials[obj] = SpecialAttack.Melee(energyInHundreds, spec) + return Result.Add.Success + } + public fun add(obj: String, spec: RangedSpecialAttack): Result.Add { val id = obj.asRSCM(RSCMType.OBJ) @@ -52,6 +68,16 @@ public class SpecialAttackRegistry @Inject constructor(private val weapons: Spec return Result.Add.Success } + /** Registers a normal-energy ranged special for a cache-native item without an RSCM alias. */ + public fun add(obj: Int, energyInHundreds: Int, spec: RangedSpecialAttack): Result.Add { + require(energyInHundreds in 10..SpecialAttackEnergy.MAX_ENERGY) + if (obj in specials) { + return Result.Add.AlreadyAdded + } + specials[obj] = SpecialAttack.Ranged(energyInHundreds, spec) + return Result.Add.Success + } + public fun add(obj: String, spec: MagicSpecialAttack): Result.Add { val id = obj.asRSCM(RSCMType.OBJ) @@ -64,6 +90,16 @@ public class SpecialAttackRegistry @Inject constructor(private val weapons: Spec return Result.Add.Success } + /** Registers a normal-energy magic special for a cache-native item without an RSCM alias. */ + public fun add(obj: Int, energyInHundreds: Int, spec: MagicSpecialAttack): Result.Add { + require(energyInHundreds in 10..SpecialAttackEnergy.MAX_ENERGY) + if (obj in specials) { + return Result.Add.AlreadyAdded + } + specials[obj] = SpecialAttack.Magic(energyInHundreds, spec) + return Result.Add.Success + } + public class Result { public sealed class Add { public data object Success : Add() diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRepository.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRepository.kt index de0368fea..105a26a2c 100644 --- a/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRepository.kt +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackRepository.kt @@ -1,6 +1,5 @@ package org.rsmod.api.specials -import dev.openrune.types.ItemServerType import jakarta.inject.Inject import org.rsmod.api.combat.commons.CombatAttack import org.rsmod.api.specials.combat.MagicSpecialAttack @@ -70,6 +69,12 @@ constructor(private val registry: SpecialAttackRegistry) { assertValidResult(specWeapon, result) } + /** Registers a melee special for a cache-native item without an RSCM alias. */ + public fun registerMelee(specWeapon: Int, energyInHundreds: Int, special: MeleeSpecialAttack) { + val result = registry.add(specWeapon, energyInHundreds, special) + assertValidResult(specWeapon, result) + } + /** * Registers the [specWeapon] special attack ([special]) as a [RangedSpecialAttack], which * activates on the player's next ranged-based attack in combat. @@ -109,6 +114,16 @@ constructor(private val registry: SpecialAttackRegistry) { assertValidResult(specWeapon, result) } + /** Registers a ranged special for a cache-native item without an RSCM alias. */ + public fun registerRanged( + specWeapon: Int, + energyInHundreds: Int, + special: RangedSpecialAttack, + ) { + val result = registry.add(specWeapon, energyInHundreds, special) + assertValidResult(specWeapon, result) + } + /** * Registers the [specWeapon] special attack ([special]) as a [MagicSpecialAttack], which * activates on the player's next magic staff-based attack in combat. @@ -152,10 +167,13 @@ constructor(private val registry: SpecialAttackRegistry) { assertValidResult(specWeapon, result) } - private fun assertValidResult( - specWeapon: String, - result: SpecialAttackRegistry.Result.Add, - ) { + /** Registers a magic special for a cache-native item without an RSCM alias. */ + public fun registerMagic(specWeapon: Int, energyInHundreds: Int, special: MagicSpecialAttack) { + val result = registry.add(specWeapon, energyInHundreds, special) + assertValidResult(specWeapon, result) + } + + private fun assertValidResult(specWeapon: Any, result: SpecialAttackRegistry.Result.Add) { when (result) { SpecialAttackRegistry.Result.Add.AlreadyAdded -> { error("Weapon already has a special attack mapped: $specWeapon") diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/combat/CombatSpecialAttacks.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/combat/CombatSpecialAttacks.kt index 60d848360..46e0b869d 100644 --- a/api/specials/src/main/kotlin/org/rsmod/api/specials/combat/CombatSpecialAttacks.kt +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/combat/CombatSpecialAttacks.kt @@ -55,4 +55,10 @@ public interface MeleeSpecialAttack : CombatSpecialAttack public interface RangedSpecialAttack : CombatSpecialAttack +/** + * A ranged special that, once armed, executes on the next game cycle even during an existing attack + * delay. [org.rsmod.api.specials.NextCycleRangedSpecialTiming] provides the coordination. + */ +public interface NextCycleRangedSpecialAttack : RangedSpecialAttack + public interface MagicSpecialAttack : CombatSpecialAttack diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergy.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergy.kt index eee5cd220..41c461bec 100644 --- a/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergy.kt +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergy.kt @@ -4,37 +4,40 @@ import org.rsmod.api.player.vars.intVarp import org.rsmod.game.entity.Player public class SpecialAttackEnergy { - private var Player.specialEnergy by intVarp("varp.sa_energy") + private var Player.specialEnergyVarp by intVarp("varp.sa_energy") - public fun hasSpecialEnergy( - player: Player, - energyInHundreds: Int, - ): Boolean { + public fun getSpecialEnergy(player: Player): Int = player.specialEnergyVarp + + /** Removes and returns the raw full bar, bypassing nominal-cost modifiers. */ + public fun drainAllSpecialEnergy(player: Player): Int { + val energy = player.specialEnergyVarp + player.specialEnergyVarp = 0 + return energy + } + + /** Restores the player's special-attack bar to its normal maximum. */ + public fun restoreFullSpecialEnergy(player: Player): Int { + val restored = (MAX_ENERGY - player.specialEnergyVarp).coerceAtLeast(0) + player.specialEnergyVarp = MAX_ENERGY + return restored + } + + public fun hasSpecialEnergy(player: Player, energyInHundreds: Int): Boolean { val cost = - SpecialAttackEnergyModifier.adjustedCost( - player = player, - baseCost = energyInHundreds, - ) + SpecialAttackEnergyModifier.adjustedCost(player = player, baseCost = energyInHundreds) - return player.specialEnergy >= cost + return player.specialEnergyVarp >= cost } - public fun takeSpecialEnergy( - player: Player, - energyInHundreds: Int, - ) { + public fun takeSpecialEnergy(player: Player, energyInHundreds: Int) { val cost = - SpecialAttackEnergyModifier.adjustedCost( - player = player, - baseCost = energyInHundreds, - ) - - require(player.specialEnergy >= cost) { - "Not enough special energy to take. " + - "Use `hasSpecialEnergy` first for validation." + SpecialAttackEnergyModifier.adjustedCost(player = player, baseCost = energyInHundreds) + + require(player.specialEnergyVarp >= cost) { + "Not enough special energy to take. " + "Use `hasSpecialEnergy` first for validation." } - player.specialEnergy -= cost + player.specialEnergyVarp -= cost } public fun isSpecializedRequirement(energyInHundreds: Int): Boolean { diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergyModifier.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergyModifier.kt index 174afd38f..097185df0 100644 --- a/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergyModifier.kt +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/energy/SpecialAttackEnergyModifier.kt @@ -4,60 +4,32 @@ import org.rsmod.api.attr.AttributeKey import org.rsmod.game.entity.Player public object SpecialAttackEnergyModifier { - public fun setCostDivisor( - player: Player, - divisor: Int, - ) { - require(divisor >= 1) { - "Special attack cost divisor must be at least 1." - } + public fun setCostDivisor(player: Player, divisor: Int) { + require(divisor >= 1) { "Special attack cost divisor must be at least 1." } player.attr[COST_DIVISOR] = divisor } - public fun adjustedCost( - player: Player, - baseCost: Int, - ): Int { + public fun adjustedCost(player: Player, baseCost: Int): Int { if (baseCost < SPECIALIZED_REQUIREMENT_THRESHOLD) { return baseCost } - val divisor = - player.attr.getOrDefault( - key = COST_DIVISOR, - default = 1, - ) + val divisor = player.attr.getOrDefault(key = COST_DIVISOR, default = 1) - return ceilDiv( - value = baseCost, - divisor = divisor, - ) + return ceilDiv(value = baseCost, divisor = divisor) } public fun isActive(player: Player): Boolean = - player.attr.getOrDefault( - key = COST_DIVISOR, - default = 1, - ) > 1 + player.attr.getOrDefault(key = COST_DIVISOR, default = 1) > 1 public fun clear(player: Player) { player.attr.remove(COST_DIVISOR) } - private fun ceilDiv( - value: Int, - divisor: Int, - ): Int = - (value + divisor - 1) / - divisor - - private val COST_DIVISOR: AttributeKey = - AttributeKey( - resetOnDeath = true, - temp = true, - ) - - private const val SPECIALIZED_REQUIREMENT_THRESHOLD: Int = - 10 + private fun ceilDiv(value: Int, divisor: Int): Int = (value + divisor - 1) / divisor + + private val COST_DIVISOR: AttributeKey = AttributeKey(resetOnDeath = true, temp = true) + + private const val SPECIALIZED_REQUIREMENT_THRESHOLD: Int = 10 } diff --git a/api/specials/src/main/kotlin/org/rsmod/api/specials/weapon/SpecialAttackWeapons.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/weapon/SpecialAttackWeapons.kt index 24d31eda8..9773c080d 100644 --- a/api/specials/src/main/kotlin/org/rsmod/api/specials/weapon/SpecialAttackWeapons.kt +++ b/api/specials/src/main/kotlin/org/rsmod/api/specials/weapon/SpecialAttackWeapons.kt @@ -1,6 +1,5 @@ package org.rsmod.api.specials.weapon -import dev.openrune.types.ItemServerType import org.rsmod.api.enums.SaEnums.sa_descriptions import org.rsmod.api.enums.SaEnums.sa_energy_requirements import org.rsmod.api.specials.energy.SpecialAttackEnergy @@ -19,6 +18,14 @@ public class SpecialAttackWeapons() { */ public fun getSpecialEnergy(objType: Int): Int? = energyRequirements[objType] + /** + * Returns whether the cache lists [objType] as having a weapon special attack. + * + * This is intentionally separate from the special-attack registry: a cache-listed weapon can + * still be awaiting its server-side combat implementation. + */ + public fun hasSpecialAttack(objType: Int): Boolean = objType in energyRequirements + public fun getSpecialDescription(objType: Int): String? = descriptions[objType] internal fun startup() { diff --git a/api/weapons/src/main/kotlin/org/rsmod/api/weapons/WeaponAttackManager.kt b/api/weapons/src/main/kotlin/org/rsmod/api/weapons/WeaponAttackManager.kt index c43b5e880..4a93af00a 100644 --- a/api/weapons/src/main/kotlin/org/rsmod/api/weapons/WeaponAttackManager.kt +++ b/api/weapons/src/main/kotlin/org/rsmod/api/weapons/WeaponAttackManager.kt @@ -1,11 +1,9 @@ package org.rsmod.api.weapons import dev.openrune.types.ItemServerType -import dev.openrune.types.ProjAnimType -import dev.openrune.types.aconverted.SpotanimType -import dev.openrune.types.aconverted.SynthType import jakarta.inject.Inject import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.magic.Spellbook import org.rsmod.api.combat.commons.styles.MeleeAttackStyle import org.rsmod.api.combat.commons.styles.RangedAttackStyle import org.rsmod.api.combat.commons.types.MeleeAttackType @@ -198,6 +196,26 @@ public class WeaponAttackManager @Inject constructor(private val manager: Player roundUp, ) + /** @see [PlayerAttackManager.rollSpellMaxHit] */ + public fun rollSpellMaxHit( + source: ProtectedAccess, + target: PathingEntity, + spell: ItemServerType, + spellbook: Spellbook?, + baseMaxHit: Int, + attackRate: Int, + sunfireRune: Boolean = false, + ): Int = + manager.rollSpellMaxHit( + source = source.player, + target = target, + spell = spell, + spellbook = spellbook, + baseMaxHit = baseMaxHit, + attackRate = attackRate, + sunfireRune = sunfireRune, + ) + /** @see [PlayerAttackManager.queueMeleeHit] */ public fun queueMeleeHit( source: ProtectedAccess, diff --git a/content/interfaces/combat-tab/src/main/kotlin/org/rsmod/content/interfaces/combat/tab/CombatTabScript.kt b/content/interfaces/combat-tab/src/main/kotlin/org/rsmod/content/interfaces/combat/tab/CombatTabScript.kt index 7a5ac19c6..44d320abf 100644 --- a/content/interfaces/combat-tab/src/main/kotlin/org/rsmod/content/interfaces/combat/tab/CombatTabScript.kt +++ b/content/interfaces/combat-tab/src/main/kotlin/org/rsmod/content/interfaces/combat/tab/CombatTabScript.kt @@ -10,6 +10,8 @@ import dev.openrune.types.enums.EnumTypeNonNullMap import dev.openrune.util.WeaponCategory import dev.openrune.util.Wearpos import jakarta.inject.Inject +import java.util.Collections +import java.util.WeakHashMap import org.rsmod.api.combat.commons.CombatStance import org.rsmod.api.combat.commons.magic.MagicSpell import org.rsmod.api.combat.commons.magic.Spellbook @@ -22,8 +24,8 @@ import org.rsmod.api.player.output.mes import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.player.protect.ProtectedAccessLauncher import org.rsmod.api.player.righthand -import org.rsmod.api.player.ui.PlayerInterfaceUpdates import org.rsmod.api.player.ui.IfOverlayButton +import org.rsmod.api.player.ui.PlayerInterfaceUpdates import org.rsmod.api.player.ui.ifClose import org.rsmod.api.player.ui.ifOpenOverlay import org.rsmod.api.player.ui.ifSetEvents @@ -34,16 +36,19 @@ import org.rsmod.api.player.vars.enumVarBit import org.rsmod.api.player.vars.enumVarp import org.rsmod.api.player.vars.intVarBit import org.rsmod.api.player.vars.intVarp -import org.rsmod.api.script.onIfClose import org.rsmod.api.script.advanced.onWearposChange +import org.rsmod.api.script.onIfClose import org.rsmod.api.script.onIfOpen import org.rsmod.api.script.onIfOverlayButton import org.rsmod.api.script.onPlayerQueue import org.rsmod.api.script.onPlayerQueueWithArgs +import org.rsmod.api.specials.NextCycleRangedSpecialTiming import org.rsmod.api.specials.SpecialAttack import org.rsmod.api.specials.SpecialAttackRegistry import org.rsmod.api.specials.SpecialAttackType +import org.rsmod.api.specials.combat.NextCycleRangedSpecialAttack import org.rsmod.api.specials.energy.SpecialAttackEnergy +import org.rsmod.api.specials.weapon.SpecialAttackWeapons import org.rsmod.api.spells.MagicSpellRegistry import org.rsmod.api.spells.autocast.AutocastWeapons import org.rsmod.events.EventBus @@ -51,8 +56,6 @@ import org.rsmod.game.entity.Player import org.rsmod.game.type.getOrNull import org.rsmod.plugin.scripts.PluginScript import org.rsmod.plugin.scripts.ScriptContext -import java.util.Collections -import java.util.WeakHashMap /* * Note: The logic and execution order in this script are designed for emulation accuracy. While @@ -69,6 +72,7 @@ constructor( private val autocast: AutocastWeapons, private val energy: SpecialAttackEnergy, private val specialReg: SpecialAttackRegistry, + private val specialWeapons: SpecialAttackWeapons, private val protectedAccess: ProtectedAccessLauncher, ) : PluginScript() { private var Player.combatStance by enumVarp("varp.com_mode") @@ -124,6 +128,7 @@ constructor( private fun Player.onWearposChange(wearpos: Wearpos) { if (wearpos == Wearpos.RightHand || wearpos == Wearpos.LeftHand) { + NextCycleRangedSpecialTiming.cancel(this) loadSavedWeaponStance() loadSavedMagicAutocast() validateStanceStyle() @@ -490,13 +495,37 @@ constructor( is SpecialAttack.Instant -> attemptInstantSpecial() null -> { resetSpecialType() - mes("This weapon does not have a special attack.") + val message = + if (specialWeapons.hasSpecialAttack(righthand.id)) { + "This weapon's special attack has not been implemented yet." + } else { + "This weapon does not have a special attack." + } + mes(message) } } } private fun Player.activateCombatSpecial() { specialType = SpecialAttackType.Weapon + scheduleNextCycleRangedSpecial() + } + + private fun Player.scheduleNextCycleRangedSpecial() { + val weapon = righthand ?: return + val special = specialReg[weapon] as? SpecialAttack.Ranged ?: return + if (special.special !is NextCycleRangedSpecialAttack) { + return + } + + val energyRequirement = special.energyInHundreds + if ( + energy.isSpecializedRequirement(energyRequirement) || + !energy.hasSpecialEnergy(this, energyRequirement) + ) { + return + } + NextCycleRangedSpecialTiming.schedule(this, weapon.id) } private fun Player.attemptInstantSpecial() { @@ -537,6 +566,7 @@ constructor( private fun Player.resetSpecialType() { specialType = SpecialAttackType.None + NextCycleRangedSpecialTiming.cancel(this) } private data class AutocastSelection(val autocastId: Int, val spell: MagicSpell) diff --git a/content/interfaces/prayer-tab/src/main/kotlin/org/rsmod/content/interfaces/prayer/tab/scripts/PrayerTabScript.kt b/content/interfaces/prayer-tab/src/main/kotlin/org/rsmod/content/interfaces/prayer/tab/scripts/PrayerTabScript.kt index 509b7dc2e..d7f206dce 100644 --- a/content/interfaces/prayer-tab/src/main/kotlin/org/rsmod/content/interfaces/prayer/tab/scripts/PrayerTabScript.kt +++ b/content/interfaces/prayer-tab/src/main/kotlin/org/rsmod/content/interfaces/prayer/tab/scripts/PrayerTabScript.kt @@ -6,6 +6,7 @@ import jakarta.inject.Inject import org.rsmod.api.player.output.ClientScripts import org.rsmod.api.player.output.mes import org.rsmod.api.player.output.soundSynth +import org.rsmod.api.player.prayer.ProtectionPrayerLockout import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.player.protect.ProtectedAccessLauncher import org.rsmod.api.player.stat.prayerLvl @@ -32,7 +33,7 @@ private constructor( private val protectedAccess: ProtectedAccessLauncher, ) : PluginScript() { override fun ScriptContext.startup() { - for ((component, prayer) in repo.prayerComponents.map { RSCM.getReverseMapping(RSCMType.COMPONENT,it.key.packed) to it.value }) { + for ((component, prayer) in repo.prayerComponents.map { RSCM.getReverseMapping(RSCMType.COMPONENT, it.key.packed) to it.value }) { onIfOverlayButton(component) { player.selectPrayer(prayer) } } onPlayerQueueWithArgs("queue.prayer_toggle") { togglePrayer(it.args) } @@ -68,6 +69,15 @@ private constructor( ClientScripts.pvpIconsComLevelRange(player, player.combatLevel) return } + // Dragon scimitar's Sever special (PvP) temporarily blocks re-activating protection + // prayers after a successful hit - see ProtectionPrayerLockout. + if ( + prayer.enabled in ProtectionPrayerLockout.PROTECTION_PRAYER_VARBITS && + ProtectionPrayerLockout.isLocked(player) + ) { + player.resyncVar(prayer.enabled) + return + } if (!prayer.hasAllRequirements(player)) { val message = player.failedRequirementMessage(prayer) player.resyncVar(prayer.enabled) diff --git a/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt b/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt index b3a79a62b..e93ebaf10 100644 --- a/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt +++ b/content/other/commands/src/main/kotlin/org/rsmod/content/other/commands/AdminCommands.kt @@ -5,7 +5,6 @@ import dev.openrune.ServerCacheManager import dev.openrune.rscm.RSCM import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType -import dev.openrune.types.ItemServerType import dev.openrune.types.NpcMode import jakarta.inject.Inject import kotlin.math.max @@ -15,9 +14,9 @@ import org.rsmod.api.area.checker.AreaChecker import org.rsmod.api.death.NpcDeathKillContext import org.rsmod.api.death.NpcDeathKillHook import org.rsmod.api.death.prepareAdminDieTest +import org.rsmod.api.death.preparePvpDeath import org.rsmod.api.instances.BossInstanceRegistry import org.rsmod.api.instances.InstanceArea -import org.rsmod.api.death.preparePvpDeath import org.rsmod.api.invtx.invAdd import org.rsmod.api.invtx.invClear import org.rsmod.api.mechanics.toxins.impl.PlayerDisease @@ -25,10 +24,10 @@ import org.rsmod.api.mechanics.toxins.impl.PlayerPoison import org.rsmod.api.mechanics.toxins.impl.PlayerVenom import org.rsmod.api.player.cheat.adminGodMode import org.rsmod.api.player.cheat.adminMaxHit -import org.rsmod.api.player.ironman.PlayerGamemode -import org.rsmod.api.player.ironman.setGamemode import org.rsmod.api.player.debug.componentClickDebug import org.rsmod.api.player.hook.TeleportType +import org.rsmod.api.player.ironman.PlayerGamemode +import org.rsmod.api.player.ironman.setGamemode import org.rsmod.api.player.output.MiscOutput import org.rsmod.api.player.output.mes import org.rsmod.api.player.output.soundSynth @@ -508,16 +507,14 @@ constructor( private fun invAdd(cheat: Cheat) = with(cheat) { val (typeName, countArg) = args.asTypeNameAndNumber(defaultNumber = 1) - val type = resolveObj(typeName) - if (type == null) { - player.mes("There is no obj mapped to: '$typeName'") - return - } + val normalizedName = "obj.$typeName" + val type = + ServerCacheManager.getItem(normalizedName.asRSCM(RSCMType.OBJ)) ?: return@with val count = countArg.toLong().coerceAtMost(Int.MAX_VALUE.toLong()).toInt() - val objName = type.name.ifEmpty { typeName } + val objName = type.name.ifEmpty { normalizedName } - val spawned = player.invAdd(player.inv, type.id, count, strict = false) + val spawned = player.invAdd(player.inv, normalizedName, count, strict = false) if (spawned.err is TransactionResult.RestrictedDummyitem) { player.mes("You can't spawn this item!") return @@ -770,14 +767,6 @@ constructor( joinToString("_") to defaultNumber.toString() } - private fun resolveObj(input: String): ItemServerType? { - val id = input.toIntOrNull() - if (id != null) { - return ServerCacheManager.getItem(id) - } - return ServerCacheManager.getItem("obj.$input".asRSCM(RSCMType.OBJ)) - } - private fun List.asTypeName(): String = joinToString("_") private fun findClosestNameMatch(input: String, names: Iterable): String? { diff --git a/content/other/consumables/src/main/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeScript.kt b/content/other/consumables/src/main/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeScript.kt new file mode 100644 index 000000000..147e14717 --- /dev/null +++ b/content/other/consumables/src/main/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeScript.kt @@ -0,0 +1,65 @@ +package org.rsmod.content.other.consumables.food + +import jakarta.inject.Inject +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.hitpoints +import org.rsmod.api.script.onOpHeld1 +import org.rsmod.api.script.onOpHeld3 +import org.rsmod.game.hit.HitType +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * The Dwarven rock cake is never consumed and never destroyed - it's a self-damage item, not + * actual food. Wiki: eating depletes 1 HP when above 2 HP (does nothing at 2 HP or below); + * guzzling depletes 10% of current HP (rounded down) plus one, which can bring the player down to + * exactly 1 HP but never lower (0 damage once already at 1 HP). Neither action can kill. + */ +class DwarvenRockCakeScript @Inject constructor() : PluginScript() { + override fun ScriptContext.startup() { + for (item in ROCK_CAKE_ITEMS) { + onOpHeld1(item) { eatRockCake() } + onOpHeld3(item) { guzzleRockCake() } + } + } + + private fun ProtectedAccess.eatRockCake() { + val damage = DwarvenRockCakeDamage.eatDamage(player.hitpoints) + if (damage <= 0) { + mes("The cake resists all attempts to eat it.") + return + } + anim(EAT_ANIM) + mes("Ow! You nearly broke a tooth!") + takeInstantHit(type = HitType.Typeless, damage = damage) + } + + private fun ProtectedAccess.guzzleRockCake() { + val damage = DwarvenRockCakeDamage.guzzleDamage(player.hitpoints) + anim(EAT_ANIM) + mes("You guzzle down some of the cake.") + if (damage > 0) { + takeInstantHit(type = HitType.Typeless, damage = damage) + } + } + + private companion object { + val ROCK_CAKE_ITEMS = + listOf("obj.hundred_dwarf_hot_rockcake", "obj.hundred_dwarf_cool_rockcake") + const val EAT_ANIM = "seq.human_eat" + } +} + +/** Pure damage math, kept separate from [ProtectedAccess] so it can be unit tested. */ +internal object DwarvenRockCakeDamage { + fun eatDamage(currentHitpoints: Int): Int = if (currentHitpoints > 2) 1 else 0 + + fun guzzleDamage(currentHitpoints: Int): Int { + if (currentHitpoints <= 1) { + return 0 + } + val raw = currentHitpoints / 10 + 1 + return raw.coerceAtMost(currentHitpoints - 1) + } +} diff --git a/content/other/special-attacks/build.gradle.kts b/content/other/special-attacks/build.gradle.kts index f31281161..a973724b3 100644 --- a/content/other/special-attacks/build.gradle.kts +++ b/content/other/special-attacks/build.gradle.kts @@ -3,6 +3,12 @@ plugins { } dependencies { + implementation(libs.fastutil) + implementation(projects.api.combat.combatCommons) + implementation(projects.api.combat.combatScripts) + implementation(projects.api.combat.combatWeapon) + implementation(projects.api.mechanics.toxins) implementation(projects.api.pluginCommons) implementation(projects.api.specials) + implementation(projects.engine.utilsBits) } diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/SpecialAttackModule.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/SpecialAttackModule.kt index bc142a500..c683f1c10 100644 --- a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/SpecialAttackModule.kt +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/SpecialAttackModule.kt @@ -1,15 +1,152 @@ package org.rsmod.content.other.special.attacks import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.content.other.special.attacks.boost.DragonBattleaxeSpecialAttack import org.rsmod.content.other.special.attacks.boost.StatBoostSpecialAttacks +import org.rsmod.content.other.special.attacks.magic.NightmareStaffSpecialAttacks +import org.rsmod.content.other.special.attacks.magic.StaffOfTheDeadSpecialAttack +import org.rsmod.content.other.special.attacks.melee.AbyssalBludgeonSpecialAttack +import org.rsmod.content.other.special.attacks.melee.AbyssalDaggerSpecialAttack +import org.rsmod.content.other.special.attacks.melee.AbyssalWhipSpecialAttack +import org.rsmod.content.other.special.attacks.melee.AncientGodswordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.AncientMaceSpecialAttack +import org.rsmod.content.other.special.attacks.melee.ArkanBladeSpecialAttack +import org.rsmod.content.other.special.attacks.melee.ArmadylGodswordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.BarrelchestAnchorSpecialAttack +import org.rsmod.content.other.special.attacks.melee.BlueMoonSpearSpecialAttack +import org.rsmod.content.other.special.attacks.melee.BoneDaggerSpecialAttack +import org.rsmod.content.other.special.attacks.melee.BrineSabreSpecialAttack +import org.rsmod.content.other.special.attacks.melee.BurningClawsSpecialAttack +import org.rsmod.content.other.special.attacks.melee.CrimsonKistenSpecialAttack +import org.rsmod.content.other.special.attacks.melee.CrystalHalberdSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DemonbaneSpecialAttacks +import org.rsmod.content.other.special.attacks.melee.DinhsBulwarkSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DogswordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.Dragon2hSwordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonCandleDaggerSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonClawsSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonDaggerSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonHalberdSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonHastaSpecialAttack import org.rsmod.content.other.special.attacks.melee.DragonLongswordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonMaceSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonScimitarSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DragonSwordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.DualMacuahuitlSpecialAttack +import org.rsmod.content.other.special.attacks.melee.ElderMaulSpecialAttack +import org.rsmod.content.other.special.attacks.melee.FangOfTheHoundSpecialAttack +import org.rsmod.content.other.special.attacks.melee.GraniteHammerSpecialAttack +import org.rsmod.content.other.special.attacks.melee.GraniteMaulSpecialAttack +import org.rsmod.content.other.special.attacks.melee.ImpactMeleeSpecialAttacks +import org.rsmod.content.other.special.attacks.melee.InfernalTecpatlSpecialAttack +import org.rsmod.content.other.special.attacks.melee.NoxiousHalberdSpecialAttack +import org.rsmod.content.other.special.attacks.melee.OsmumtenFangSpecialAttack +import org.rsmod.content.other.special.attacks.melee.RuneClawsSpecialAttack +import org.rsmod.content.other.special.attacks.melee.SaradominBlessedSwordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.SaradominSwordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.SoulreaperAxeSpecialAttack +import org.rsmod.content.other.special.attacks.melee.StatiusWarhammerSpecialAttack +import org.rsmod.content.other.special.attacks.melee.SunspearSpecialAttack +import org.rsmod.content.other.special.attacks.melee.ThunderKhopeshSpecialAttack +import org.rsmod.content.other.special.attacks.melee.VampyreFlailSpecialAttack +import org.rsmod.content.other.special.attacks.melee.VestaLongswordSpecialAttack +import org.rsmod.content.other.special.attacks.melee.VestaSpearSpecialAttack +import org.rsmod.content.other.special.attacks.melee.VoidwakerSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.ArmadylCrossbowSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.BallistaSpecialAttack import org.rsmod.content.other.special.attacks.ranged.DarkBowSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.DorgeshuunCrossbowSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.DragonCrossbowSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.DragonKnifeSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.DragonThrownaxeSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.EclipseAtlatlSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.MagicBowSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.MagicShortbowSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.MorrigansJavelinSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.MorrigansThrowingAxeSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.RosewoodBlowpipeSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.RuneThrownaxeSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.SeercullSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.TonalzticsOfRalosSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.ToxicBlowpipeSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.WebweaverBowSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.ZaryteCrossbowSpecialAttack import org.rsmod.plugin.module.PluginModule +// Weapons that need the still-undecided engine-diff-scale subsystems (poison/burn/venom +// services, shove-stun/bind, blowpipe ammo tracking, PvP area attacks, a few extra magic/hybrid +// roll variants) are parked in disabled-tier-b/. class SpecialAttackModule : PluginModule() { override fun bind() { + addSetBinding(DragonBattleaxeSpecialAttack::class.java) addSetBinding(StatBoostSpecialAttacks::class.java) - addSetBinding(DarkBowSpecialAttack::class.java) + addSetBinding(NightmareStaffSpecialAttacks::class.java) + addSetBinding(StaffOfTheDeadSpecialAttack::class.java) + addSetBinding(AbyssalBludgeonSpecialAttack::class.java) + addSetBinding(AbyssalDaggerSpecialAttack::class.java) + addSetBinding(AbyssalWhipSpecialAttack::class.java) + addSetBinding(AncientGodswordSpecialAttack::class.java) + addSetBinding(AncientMaceSpecialAttack::class.java) + addSetBinding(ArkanBladeSpecialAttack::class.java) + addSetBinding(ArmadylGodswordSpecialAttack::class.java) + addSetBinding(BarrelchestAnchorSpecialAttack::class.java) + addSetBinding(BlueMoonSpearSpecialAttack::class.java) + addSetBinding(BoneDaggerSpecialAttack::class.java) + addSetBinding(BrineSabreSpecialAttack::class.java) + addSetBinding(BurningClawsSpecialAttack::class.java) + addSetBinding(CrimsonKistenSpecialAttack::class.java) + addSetBinding(CrystalHalberdSpecialAttack::class.java) + addSetBinding(DemonbaneSpecialAttacks::class.java) + addSetBinding(DinhsBulwarkSpecialAttack::class.java) + addSetBinding(DogswordSpecialAttack::class.java) + addSetBinding(Dragon2hSwordSpecialAttack::class.java) + addSetBinding(DragonCandleDaggerSpecialAttack::class.java) + addSetBinding(DragonClawsSpecialAttack::class.java) + addSetBinding(DragonDaggerSpecialAttack::class.java) + addSetBinding(DragonHalberdSpecialAttack::class.java) + addSetBinding(DragonHastaSpecialAttack::class.java) addSetBinding(DragonLongswordSpecialAttack::class.java) + addSetBinding(DragonMaceSpecialAttack::class.java) + addSetBinding(DragonScimitarSpecialAttack::class.java) + addSetBinding(DragonSwordSpecialAttack::class.java) + addSetBinding(DualMacuahuitlSpecialAttack::class.java) + addSetBinding(ElderMaulSpecialAttack::class.java) + addSetBinding(FangOfTheHoundSpecialAttack::class.java) + addSetBinding(GraniteHammerSpecialAttack::class.java) + addSetBinding(GraniteMaulSpecialAttack::class.java) + addSetBinding(ImpactMeleeSpecialAttacks::class.java) + addSetBinding(InfernalTecpatlSpecialAttack::class.java) + addSetBinding(NoxiousHalberdSpecialAttack::class.java) + addSetBinding(OsmumtenFangSpecialAttack::class.java) + addSetBinding(RuneClawsSpecialAttack::class.java) + addSetBinding(SaradominBlessedSwordSpecialAttack::class.java) + addSetBinding(SaradominSwordSpecialAttack::class.java) + addSetBinding(SoulreaperAxeSpecialAttack::class.java) + addSetBinding(StatiusWarhammerSpecialAttack::class.java) + addSetBinding(SunspearSpecialAttack::class.java) + addSetBinding(ThunderKhopeshSpecialAttack::class.java) + addSetBinding(VampyreFlailSpecialAttack::class.java) + addSetBinding(VestaLongswordSpecialAttack::class.java) + addSetBinding(VestaSpearSpecialAttack::class.java) + addSetBinding(VoidwakerSpecialAttack::class.java) + addSetBinding(ArmadylCrossbowSpecialAttack::class.java) + addSetBinding(BallistaSpecialAttack::class.java) + addSetBinding(DarkBowSpecialAttack::class.java) + addSetBinding(DorgeshuunCrossbowSpecialAttack::class.java) + addSetBinding(DragonCrossbowSpecialAttack::class.java) + addSetBinding(DragonKnifeSpecialAttack::class.java) + addSetBinding(DragonThrownaxeSpecialAttack::class.java) + addSetBinding(EclipseAtlatlSpecialAttack::class.java) + addSetBinding(MagicBowSpecialAttack::class.java) + addSetBinding(MagicShortbowSpecialAttack::class.java) + addSetBinding(MorrigansJavelinSpecialAttack::class.java) + addSetBinding(MorrigansThrowingAxeSpecialAttack::class.java) + addSetBinding(RosewoodBlowpipeSpecialAttack::class.java) + addSetBinding(RuneThrownaxeSpecialAttack::class.java) + addSetBinding(SeercullSpecialAttack::class.java) + addSetBinding(TonalzticsOfRalosSpecialAttack::class.java) + addSetBinding(ToxicBlowpipeSpecialAttack::class.java) + addSetBinding(WebweaverBowSpecialAttack::class.java) + addSetBinding(ZaryteCrossbowSpecialAttack::class.java) } } diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeSpecialAttack.kt new file mode 100644 index 000000000..fc4fc4a0b --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeSpecialAttack.kt @@ -0,0 +1,54 @@ +package org.rsmod.content.other.special.attacks.boost + +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository + +/** + * Rampage: trade 10% of the current Attack, Defence, Ranged, and Magic levels for Strength. + */ +class DragonBattleaxeSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerInstant("obj.dragon_battleaxe", ::rampage) + registerInstant("obj.bh_dragon_battleaxe_corrupted", ::rampage) + } + + private fun rampage(access: ProtectedAccess): Boolean = + with(access) { + val drained = DRAINED_STATS.sumOf { stat -> + val amount = DragonBattleaxeDrain.drainAmount(stat(stat)) + if (amount > 0) { + statSub(stat, constant = amount, percent = 0) + } + amount + } + + statBoost("stat.strength", constant = DragonBattleaxeDrain.strengthBoost(drained), percent = 0) + anim("seq.rampage") + spotanim("spotanim.sp_attackglow_red") + true + } + + private companion object { + val DRAINED_STATS = + listOf( + "stat.attack", + "stat.defence", + "stat.ranged", + "stat.magic", + ) + } +} + +/** + * Pure Rampage math, kept separate from [ProtectedAccess] so the drain/boost formula can be unit + * tested directly instead of only through a live stat roll. + */ +internal object DragonBattleaxeDrain { + /** Wiki: drains 10% of the current level for each of Attack, Defence, Ranged, and Magic. */ + fun drainAmount(currentLevel: Int): Int = currentLevel / 10 + + /** Wiki: Strength is boosted by 10 plus a quarter of the total levels drained. */ + fun strengthBoost(totalDrained: Int): Int = 10 + (totalDrained / 4) +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/StatBoostSpecialAttacks.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/StatBoostSpecialAttacks.kt index c6695f30c..4dbf5706f 100644 --- a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/StatBoostSpecialAttacks.kt +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/StatBoostSpecialAttacks.kt @@ -13,28 +13,59 @@ class StatBoostSpecialAttacks @Inject constructor(private val worldRepo: WorldRe override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { registerInstant("obj.dragon_axe", ::lumberUpRed) registerInstant("obj.trailblazer_axe_no_infernal", ::lumberUpRed) + registerInstant("obj.trailblazer_reloaded_axe_no_infernal", ::lumberUpRed) + registerInstant("obj.dragon_axe_2h", ::lumberUpRed) + registerInstant("obj.league_trailblazer_axe", ::lumberUpRed) registerInstant("obj.3a_axe", ::lumberUpSilver) + registerInstant("obj.3a_axe_2h", ::lumberUpSilver) registerInstant("obj.infernal_axe", ::lumberUpRed) + registerInstant("obj.infernal_axe_empty", ::lumberUpRed) registerInstant("obj.trailblazer_axe", ::lumberUpRed) + registerInstant("obj.trailblazer_axe_empty", ::lumberUpRed) + registerInstant("obj.trailblazer_reloaded_axe", ::lumberUpRed) + registerInstant("obj.trailblazer_reloaded_axe_empty", ::lumberUpRed) registerInstant("obj.crystal_axe", ::lumberUpSilver) + registerInstant("obj.crystal_axe_inactive", ::lumberUpSilver) + registerInstant("obj.crystal_axe_2h", ::lumberUpSilver) + registerInstant("obj.crystal_axe_2h_inactive", ::lumberUpSilver) registerInstant("obj.dragon_harpoon", ::fishstabberDragonHarpoon) registerInstant("obj.trailblazer_harpoon_no_infernal", ::fishstabberDragonHarpoonOr) + registerInstant("obj.trailblazer_reloaded_harpoon_no_infernal", ::fishstabberDragonHarpoonOr) + registerInstant("obj.league_trailblazer_harpoon", ::fishstabberDragonHarpoonOr) registerInstant("obj.infernal_harpoon", ::fishstabberInfernalHarpoon) + registerInstant("obj.infernal_harpoon_empty", ::fishstabberInfernalHarpoon) registerInstant("obj.trailblazer_harpoon", ::fishstabberInfernalHarpoonOr) registerInstant("obj.trailblazer_harpoon_empty", ::fishstabberInfernalHarpoonOr) + registerInstant("obj.trailblazer_reloaded_harpoon", ::fishstabberInfernalHarpoonOr) + registerInstant("obj.trailblazer_reloaded_harpoon_empty", ::fishstabberInfernalHarpoonOr) registerInstant("obj.crystal_harpoon", ::fishstabberCrystalHarpoon) + registerInstant("obj.crystal_harpoon_inactive", ::fishstabberCrystalHarpoon) registerInstant("obj.dragon_pickaxe", ::rockKnockerDragonPickaxe) registerInstant("obj.zalcano_pickaxe", ::rockKnockerDragonPickaxeOrZalcano) registerInstant("obj.trailblazer_pickaxe_no_infernal", ::rockKnockerDragonPickaxeOrTrailblazer) + registerInstant("obj.trailblazer_reloaded_pickaxe_no_infernal", ::rockKnockerDragonPickaxeOrTrailblazer) + registerInstant("obj.league_trailblazer_pickaxe", ::rockKnockerDragonPickaxeOrTrailblazer) registerInstant("obj.dragon_pickaxe_pretty", ::rockKnockerDragonPickaxeUpgraded) registerInstant("obj.infernal_pickaxe", ::rockKnockerInfernalPickaxe) registerInstant("obj.infernal_pickaxe_empty", ::rockKnockerInfernalPickaxe) registerInstant("obj.trailblazer_pickaxe", ::rockKnockerInfernalPickaxeOr) registerInstant("obj.trailblazer_pickaxe_empty", ::rockKnockerInfernalPickaxeOr) + registerInstant("obj.trailblazer_reloaded_pickaxe", ::rockKnockerInfernalPickaxeOr) + registerInstant("obj.trailblazer_reloaded_pickaxe_empty", ::rockKnockerInfernalPickaxeOr) registerInstant("obj.3a_pickaxe", ::rockKnockerThirdAgePickaxe) registerInstant("obj.crystal_pickaxe", ::rockKnockerCrystalPickaxe) + registerInstant("obj.crystal_pickaxe_inactive", ::rockKnockerCrystalPickaxe) + + registerInstant("obj.excalibur", ::sanctuary) + } + + private fun sanctuary(access: ProtectedAccess): Boolean { + access.statBoost("stat.defence", constant = 8, percent = 0) + access.anim("seq.sanctuary") + access.soundSynth(SANCTUARY_SOUND) + return true } private fun lumberUpRed(access: ProtectedAccess): Boolean { @@ -51,6 +82,7 @@ class StatBoostSpecialAttacks @Inject constructor(private val worldRepo: WorldRe anim("seq.dragon_smallaxe_anim") spotanim(spot, height = 96, slot = constants.spotanim_slot_combat) soundArea(worldRepo, coords, "synth.clobber", radius = 1) + soundSynth(LUMBER_UP_SOUND) return true } @@ -137,6 +169,14 @@ class StatBoostSpecialAttacks @Inject constructor(private val worldRepo: WorldRe say("Smashing!") anim(seq) soundArea(worldRepo, coords, "synth.found_gem", radius = 1) + soundSynth(ROCK_KNOCKER_SOUND) return true } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` names exist for these. */ + const val SANCTUARY_SOUND = 2539 + const val LUMBER_UP_SOUND = 2531 + const val ROCK_KNOCKER_SOUND = 2655 + } } diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialAttacks.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialAttacks.kt new file mode 100644 index 000000000..3c54d91eb --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialAttacks.kt @@ -0,0 +1,200 @@ +package org.rsmod.content.other.special.attacks.magic + +import kotlin.math.min +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.magicLvl +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statAdd +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MagicSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * The Nightmare staff variants are spell-like, but their special attacks are built-in staff + * attacks rather than autocast spells. They therefore intentionally use [CombatAttack.Staff]: + * no spell runes or weapon charges are consumed. + */ +class NightmareStaffSpecialAttacks : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val immolate = Immolate(manager) + registerMagic("obj.nightmare_staff_volatile", immolate) + registerMagic("obj.br_nightmare_staff_volatile", immolate) + registerMagic("obj.deadman_blighted_volatile_staff", immolate) + registerMagic("obj.deadman_nightmare_staff_volatile", immolate) + + registerMagic("obj.nightmare_staff_eldritch", Invocate(manager)) + } + + private class Immolate(private val manager: SpecialAttackManager) : MagicSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Staff, + ): Boolean { + immolate(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Staff, + ): Boolean { + immolate(target, attack) + return true + } + + private fun ProtectedAccess.immolate( + target: PathingEntity, + attack: CombatAttack.Staff, + ) { + anim(VOLATILE_CAST_ANIM) + spotanim( + spot = VOLATILE_CAST_SPOTANIM, + slot = constants.spotanim_slot_combat, + height = SPOTANIM_HEIGHT, + ) + target.spotanim( + spot = VOLATILE_HIT_SPOTANIM, + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val successful = + manager.rollStaffAccuracy( + source = this, + target = target, + attackStyle = attack.style, + multiplier = VOLATILE_ACCURACY_MULTIPLIER, + ) + val damage = + if (successful) { + manager.rollStaffMaxHit( + source = this, + target = target, + baseMaxHit = NightmareStaffSpecialDamage.volatileBaseMaxHit(player.magicLvl), + multiplier = 1.0, + ) + } else { + 0 + } + + statAdvance("stat.magic", SPECIAL_CAST_XP) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMagicHit(this, target, damage, clientDelay = 0) + manager.continueCombat(this, target) + } + } + + private class Invocate(private val manager: SpecialAttackManager) : MagicSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Staff, + ): Boolean { + invocate(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Staff, + ): Boolean { + invocate(target, attack) + return true + } + + private fun ProtectedAccess.invocate( + target: PathingEntity, + attack: CombatAttack.Staff, + ) { + anim(ELDRITCH_CAST_ANIM) + spotanim( + spot = ELDRITCH_CAST_SPOTANIM, + slot = constants.spotanim_slot_combat, + height = SPOTANIM_HEIGHT, + ) + target.spotanim( + spot = ELDRITCH_HIT_SPOTANIM, + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val successful = + manager.rollStaffAccuracy( + source = this, + target = target, + attackStyle = attack.style, + multiplier = 1.0, + ) + val rawDamage = + if (successful) { + manager.rollStaffMaxHit( + source = this, + target = target, + baseMaxHit = NightmareStaffSpecialDamage.eldritchBaseMaxHit(player.magicLvl), + multiplier = 1.0, + ) + } else { + 0 + } + + val source = player + statAdvance("stat.magic", SPECIAL_CAST_XP) + manager.giveCombatXp(this, target, attack, rawDamage) + manager.queueMagicHit(source = this, target = target, damage = rawDamage, clientDelay = 0) + // Real OSRS doesn't clamp damage after the roll, so the already-known pre-mitigation + // rawDamage is the authentic value to restore prayer from - no impact callback needed. + if (rawDamage > 0) { + restoreInvocatePrayer(source, rawDamage) + } + manager.continueCombat(this, target) + } + + private fun restoreInvocatePrayer(source: Player, rawDamage: Int) { + val restore = rawDamage / 2 + if (restore <= 0) { + return + } + val current = source.stat("stat.prayer") + val added = min(ELDRITCH_PRAYER_CAP, current + restore) - current + if (added > 0) { + source.statAdd("stat.prayer", constant = added, percent = 0) + } + } + } + + private companion object { + const val VOLATILE_ACCURACY_MULTIPLIER = 1.5 + const val SPECIAL_CAST_XP = 10.0 + const val ELDRITCH_PRAYER_CAP = 120 + const val SPOTANIM_HEIGHT = 96 + + const val VOLATILE_CAST_ANIM = "seq.nightmare_staff_special" + const val VOLATILE_CAST_SPOTANIM = "spotanim.nightmare_staff_volatile_cast_spotanim" + const val VOLATILE_HIT_SPOTANIM = "spotanim.nightmare_staff_volatile_hit_spotanim" + + const val ELDRITCH_CAST_ANIM = "seq.nightmare_staff_special" + const val ELDRITCH_CAST_SPOTANIM = "spotanim.nightmare_staff_eldritch_cast_spotanim" + const val ELDRITCH_HIT_SPOTANIM = "spotanim.nightmare_staff_eldritch_hit_spotanim" + } +} + +/** Base spell hit before the staff's normal magic-damage bonuses are applied. */ +internal object NightmareStaffSpecialDamage { + fun volatileBaseMaxHit(visibleMagicLevel: Int): Int = + scaledBaseMaxHit(visibleMagicLevel, VOLATILE_BASE_MAX_HIT) + + fun eldritchBaseMaxHit(visibleMagicLevel: Int): Int = + scaledBaseMaxHit(visibleMagicLevel, ELDRITCH_BASE_MAX_HIT) + + private fun scaledBaseMaxHit(visibleMagicLevel: Int, ceiling: Int): Int = + ((ceiling * visibleMagicLevel.coerceAtLeast(0)) / MAX_MAGIC_LEVEL + 1).coerceAtMost(ceiling) + + private const val MAX_MAGIC_LEVEL = 99 + private const val VOLATILE_BASE_MAX_HIT = 58 + private const val ELDRITCH_BASE_MAX_HIT = 44 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/StaffOfTheDeadSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/StaffOfTheDeadSpecialAttack.kt new file mode 100644 index 000000000..2da843ac1 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/StaffOfTheDeadSpecialAttack.kt @@ -0,0 +1,68 @@ +package org.rsmod.content.other.special.attacks.magic + +import org.rsmod.api.config.constants +import org.rsmod.api.player.hit.modifier.PowerOfDeathMeleeProtection +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository + +/** + * Power of Death: consume 100% special attack energy to halve incoming melee damage for one + * minute while a Staff of the Dead family weapon remains equipped. + * + * The cache maps every registered variant below to 1000 special-energy units. The direct sequence + * IDs are cache-native because this revision does not expose RSCM aliases for them. + */ +class StaffOfTheDeadSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerInstant("obj.sotd", ::staffOfTheDeadPower) + registerInstant("obj.br_sotd", ::staffOfTheDeadPower) + registerInstant("obj.staff_of_light", ::staffOfLightPower) + registerInstant("obj.staff_of_balance", ::staffOfBalancePower) + + registerInstant("obj.toxic_sotd", ::toxicStaffOfTheDeadPower) + registerInstant("obj.toxic_sotd_charged", ::toxicStaffOfTheDeadPower) + registerInstant("obj.toxic_sotd_deadman", ::toxicStaffOfTheDeadPower) + registerInstant("obj.toxic_sotd_charged_deadman", ::toxicStaffOfTheDeadPower) + } + + private fun staffOfTheDeadPower(access: ProtectedAccess): Boolean = + access.activatePowerOfDeath(STAFF_OF_THE_DEAD_SEQUENCE, STAFF_OF_THE_DEAD_SPOTANIM) + + private fun staffOfLightPower(access: ProtectedAccess): Boolean = + access.activatePowerOfDeath(STAFF_OF_LIGHT_SEQUENCE, STAFF_OF_LIGHT_SPOTANIM) + + private fun staffOfBalancePower(access: ProtectedAccess): Boolean = + access.activatePowerOfDeath(STAFF_OF_BALANCE_SEQUENCE, STAFF_OF_BALANCE_SPOTANIM) + + private fun toxicStaffOfTheDeadPower(access: ProtectedAccess): Boolean = + access.activatePowerOfDeath(TOXIC_STAFF_OF_THE_DEAD_SEQUENCE, STAFF_OF_THE_DEAD_SPOTANIM) + + private fun ProtectedAccess.activatePowerOfDeath(sequence: String, spotanim: String): Boolean { + PowerOfDeathMeleeProtection.activate(player) + player.anim(sequence, priority = POWER_OF_DEATH_SEQUENCE_PRIORITY) + player.spotanim( + spotanim, + height = POWER_OF_DEATH_SPOTANIM_HEIGHT, + slot = constants.spotanim_slot_combat, + ) + player.mes(POWER_OF_DEATH_MESSAGE) + return true + } + + private companion object { + const val STAFF_OF_THE_DEAD_SEQUENCE = "seq.sotd_special" + const val STAFF_OF_THE_DEAD_SPOTANIM = "spotanim.sotd_special_start" + const val STAFF_OF_LIGHT_SEQUENCE = "seq.staff_of_light_special" + const val STAFF_OF_LIGHT_SPOTANIM = "spotanim.staff_of_light_special_start" + const val STAFF_OF_BALANCE_SEQUENCE = "seq.staff_of_balance_special" + const val STAFF_OF_BALANCE_SPOTANIM = "spotanim.staff_of_balance_special_start" + const val TOXIC_STAFF_OF_THE_DEAD_SEQUENCE = "seq.sotd_special_toxic_charged" + + const val POWER_OF_DEATH_SEQUENCE_PRIORITY = 6 + const val POWER_OF_DEATH_SPOTANIM_HEIGHT = 300 + const val POWER_OF_DEATH_MESSAGE = "Spirits of deceased evildoers offer you their protection." + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonSpecialAttack.kt new file mode 100644 index 000000000..c7c41d5ef --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonSpecialAttack.kt @@ -0,0 +1,93 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Penance gains 0.5% maximum damage for every visible Prayer level the attacker is missing. + */ +class AbyssalBludgeonSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.abyssal_bludgeon", Penance(manager)) + } + + private class Penance(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + penance(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + penance(target, attack) + return true + } + + private fun ProtectedAccess.penance( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.abyssal_bludgeon_special_attack") + // Swing reuses Granite maul's "quicksmash" sound; the miasma pool gets its own + // "smokepuff" cue. Wiki-sourced, unaliased in this cache's gamevals. + soundSynth(QUICKSMASH_SOUND) + soundSynth(MIASMA_POOL_SOUND, delay = MIASMA_POOL_SOUND_DELAY) + target.spotanim( + spot = "spotanim.abyssal_miasma_spotanim_bludgeon", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val missingPrayer = + AbyssalBludgeonDamage.missingPrayer( + basePrayer = statBase("stat.prayer"), + currentPrayer = stat("stat.prayer"), + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = AbyssalBludgeonDamage.damageMultiplier(missingPrayer), + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for either. */ + const val QUICKSMASH_SOUND = 2715 + const val MIASMA_POOL_SOUND = 1930 + + const val MIASMA_POOL_SOUND_DELAY = 20 + } +} + +/** + * Pure Penance math, kept separate from [ProtectedAccess] so the missing-prayer scaling can be + * unit tested directly instead of only through a live stat roll. + */ +internal object AbyssalBludgeonDamage { + /** Prayer drained by combat only lowers current Prayer, never base - never negative. */ + fun missingPrayer(basePrayer: Int, currentPrayer: Int): Int = + (basePrayer - currentPrayer).coerceAtLeast(0) + + /** Wiki: 0.5% extra max hit per Prayer point missing. */ + fun damageMultiplier(missingPrayer: Int): Double = 1.0 + (missingPrayer * 0.005) +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerSpecialAttack.kt new file mode 100644 index 000000000..e5dad8095 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerSpecialAttack.kt @@ -0,0 +1,118 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Abyssal Puncture performs one accuracy roll against Slash defence, then follows it with two + * independently rolled damage hits when that roll succeeds. + */ +class AbyssalDaggerSpecialAttack : SpecialAttackMap { + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val ABYSSAL_PUNCTURE_SOUND = 2537 + } + + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val puncture = AbyssalPuncture(manager, maxHitMultiplier = 0.85) + val imbuedPuncture = AbyssalPuncture(manager, maxHitMultiplier = 0.95) + + registerMelee("obj.abyssal_dagger", puncture) + registerMelee("obj.abyssal_dagger_p", puncture) + registerMelee("obj.abyssal_dagger_p+", puncture) + registerMelee("obj.abyssal_dagger_p++", puncture) + + registerMelee("obj.bh_abyssal_dagger_imbue", imbuedPuncture) + registerMelee("obj.bh_abyssal_dagger_p_imbue", imbuedPuncture) + registerMelee("obj.bh_abyssal_dagger_p+_imbue", imbuedPuncture) + registerMelee("obj.bh_abyssal_dagger_p++_imbue", imbuedPuncture) + } + + private class AbyssalPuncture( + private val manager: SpecialAttackManager, + private val maxHitMultiplier: Double, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + puncture(target, attack, secondHitDelay = AbyssalDaggerTiming.secondHitDelay(targetIsPlayer = false)) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + puncture(target, attack, secondHitDelay = AbyssalDaggerTiming.secondHitDelay(targetIsPlayer = true)) + return true + } + + private fun ProtectedAccess.puncture( + target: PathingEntity, + attack: CombatAttack.Melee, + secondHitDelay: Int, + ) { + anim("seq.abyssal_dagger_special") + soundSynth(ABYSSAL_PUNCTURE_SOUND) + spotanim( + spot = "spotanim.abyssal_dagger_special_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Slash, + multiplier = 1.25, + ) + val first = + if (successful) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = maxHitMultiplier, + ) + } else { + 0 + } + val second = + if (successful) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = maxHitMultiplier, + ) + } else { + 0 + } + + manager.giveCombatXp(this, target, attack, first + second) + manager.queueMeleeHit(this, target, first) + manager.queueMeleeHit(this, target, second, delay = secondHitDelay) + manager.continueCombat(this, target) + } + } +} + +/** Pure timing, kept separate from [ProtectedAccess] so it can be unit tested. */ +internal object AbyssalDaggerTiming { + fun secondHitDelay(targetIsPlayer: Boolean): Int = if (targetIsPlayer) 1 else 2 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalWhipSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalWhipSpecialAttack.kt new file mode 100644 index 000000000..53b218c94 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalWhipSpecialAttack.kt @@ -0,0 +1,102 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.output.UpdateRun +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Energy Drain rolls 25% more accurately against Slash defence. A damaging PvP impact transfers + * up to 10% run energy from its target to the attacker. + */ +class AbyssalWhipSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val energyDrain = EnergyDrain(manager) + registerMelee("obj.abyssal_whip", energyDrain) + registerMelee("obj.abyssal_whip_lava", energyDrain) + registerMelee("obj.abyssal_whip_ice", energyDrain) + registerMelee("obj.abyssal_tentacle", energyDrain) + registerMelee("obj.br_abyssal_whip", energyDrain) + registerMelee("obj.league_3_whip", energyDrain) + registerMelee("obj.league_3_whip_tentacle", energyDrain) + } + + private class EnergyDrain(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + energyDrain(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + energyDrain(target, attack) + return true + } + + private fun ProtectedAccess.energyDrain( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.slayer_abyssal_whip_attack") + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.25, + maxHitMultiplier = 1.0, + blockType = MeleeAttackType.Slash, + ) + val source = player + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known pre-clamp + // `damage` here is the authentic value to gate the energy transfer on - no need for + // an impact callback to observe it after the fact. + target.spotanim( + spot = "spotanim.sp_attack_abyssal_whip", + slot = constants.spotanim_slot_combat, + height = 96, + ) + if (damage > 0) { + (target as? Player)?.let { transferRunEnergy(source, it) } + } + manager.continueCombat(this, target) + } + + private fun transferRunEnergy(source: Player, target: Player) { + val transferable = + minOf( + RUN_ENERGY_TRANSFER, + target.runEnergy.coerceAtLeast(0), + (constants.run_max_energy - source.runEnergy).coerceAtLeast(0), + ) + if (transferable > 0) { + source.runEnergy = (source.runEnergy + transferable).coerceAtMost(constants.run_max_energy) + target.runEnergy = (target.runEnergy - transferable).coerceAtLeast(0) + UpdateRun.energy(source, source.runEnergy) + UpdateRun.energy(target, target.runEnergy) + } + target.mes("You feel drained!") + } + } + + private companion object { + const val RUN_ENERGY_TRANSFER = 100 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientGodswordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientGodswordSpecialAttack.kt new file mode 100644 index 000000000..bb8a9df06 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientGodswordSpecialAttack.kt @@ -0,0 +1,279 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import kotlin.math.min +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.game.process.GameLifecycle +import org.rsmod.api.npc.hit.modifier.NpcHitModifier +import org.rsmod.api.npc.hit.queueHit +import org.rsmod.api.player.hit.queueImpactHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.baseHitpointsLvl +import org.rsmod.api.player.stat.hitpoints +import org.rsmod.api.player.stat.statHeal +import org.rsmod.api.script.onEvent +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.queue.WorldQueueList +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Blood Sacrifice is a doubled-accuracy Slash hit with a 10% maximum-hit increase. A successful + * hit marks its target for eight cycles; the target escapes by ending at least five tiles away + * from the attacker. Otherwise it receives an unprotected 25-damage typeless hit. + */ +class AncientGodswordSpecialAttack +@Inject +constructor(private val sacrifice: AncientGodswordBloodSacrifice) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val bloodSacrifice = BloodSacrifice(manager, sacrifice) + registerMelee("obj.ancient_godsword", bloodSacrifice) + registerMelee("obj.br_ancient_godsword", bloodSacrifice) + } + + private class BloodSacrifice( + private val manager: SpecialAttackManager, + private val sacrifice: AncientGodswordBloodSacrifice, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + bloodSacrifice(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + bloodSacrifice(target, attack) + return true + } + + private fun ProtectedAccess.bloodSacrifice( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.ngs_special_player") + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 2.0, + maxHitMultiplier = 1.1, + blockType = MeleeAttackType.Slash, + ) + val source = player + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(source = this, target = target, damage = damage) + // Real OSRS damage isn't clamped after the roll (no "overhit" cap to remaining HP), + // so `damage` here is already the value that will land - no need to wait for an + // impact callback to know whether the hit was nonzero. + if (damage > 0) { + // Weapon-effect spotanims in this combat slot are attacker-anchored so they + // sweep with the swing (same pattern as Dragon claws' verified slash trail) - + // not target-anchored, which is what made this look like a flat, static shape + // planted on the ground instead of a blade effect on the sword. + spotanim( + spot = "spotanim.ngs_special_spotanim", + slot = constants.spotanim_slot_combat, + height = 48, + ) + sacrifice.mark(source, target) + } + manager.continueCombat(this, target) + } + } +} + +/** + * Owns independent Blood Sacrifice marks so concurrent specials can stack on one target. + * + * The world queue is processed before entity queues. Consequently, when its eight-cycle delay + * expires, a one-cycle target hit queue resolves in that same entity cycle rather than adding a + * ninth cycle. The LateCycle hook sees teleport flags before post-tick cleanup and cancels marks + * on targets that teleport or leave an instance. + */ +@Singleton +class AncientGodswordBloodSacrifice +@Inject +constructor( + private val worldQueues: WorldQueueList, + private val npcHitModifier: NpcHitModifier, +) { + private val marks = mutableListOf() + + /** + * Applies a Blood Sacrifice mark. + * + * [playerDamage], [playerHealCap], and [playerHitType] retain the normal Ancient godsword + * defaults. They are configurable for the Deadman dogsword, whose documented PvP copy of + * the effect is a 15-damage Magic hit with a 10-hitpoint healing cap. + */ + fun mark( + source: Player, + target: PathingEntity, + playerDamage: Int = BLOOD_SACRIFICE_DAMAGE, + playerHealCap: Int = PLAYER_HEAL_CAP, + playerHitType: HitType = HitType.Typeless, + ) { + val mark = + Mark( + source = source, + sourceUid = source.uid.packed, + target = target, + targetUid = target.uidPacked(), + targetBaseHitpoints = target.baseHitpoints(), + playerDamage = playerDamage, + playerHealCap = playerHealCap, + playerHitType = playerHitType, + ) + marks += mark + worldQueues.add(BLOOD_SACRIFICE_DELAY) { resolve(mark) } + } + + fun cancelTeleportedTargets() { + for (mark in marks) { + if (mark.target.pendingTeleport || mark.target.pendingTelejump || !mark.targetIsAlive()) { + mark.cancelled = true + } + } + marks.removeAll { it.cancelled } + } + + private fun resolve(mark: Mark) { + marks.remove(mark) + if (mark.cancelled || !mark.isValid()) { + return + } + if (!mark.target.isWithinDistance(mark.source, BLOOD_SACRIFICE_ESCAPE_DISTANCE - 1)) { + return + } + + // Real OSRS doesn't clamp damage after the roll, and both hits below use a fixed, + // already-known damage value (never a live roll) - so the heal can use that same known + // value directly instead of waiting on an impact callback. worldQueues.add mirrors the + // hit's own TARGET_HIT_DELAY so the heal still lands on the same cycle the hit does. + when (val target = mark.target) { + is Player -> { + target.queueImpactHit( + source = mark.source, + delay = TARGET_HIT_DELAY, + type = mark.playerHitType, + damage = mark.playerDamage, + ) + worldQueues.add(TARGET_HIT_DELAY) { heal(mark, mark.playerDamage) } + } + + is Npc -> { + target.queueHit( + source = mark.source, + delay = TARGET_HIT_DELAY, + type = HitType.Typeless, + damage = BLOOD_SACRIFICE_DAMAGE, + modifier = npcHitModifier, + ) + worldQueues.add(TARGET_HIT_DELAY) { heal(mark, BLOOD_SACRIFICE_DAMAGE) } + } + } + } + + private fun heal(mark: Mark, damage: Int) { + if (damage <= 0 || !mark.sourceIsAlive()) { + return + } + val healCap = if (mark.target is Player) mark.playerHealCap else NPC_HEAL_CAP + val amount = + BloodSacrificeHeal.healAmount( + damage = damage, + targetBaseHitpoints = mark.targetBaseHitpoints, + healCap = healCap, + ) + if (amount > 0) { + mark.source.statHeal("stat.hitpoints", constant = amount, percent = 0) + } + } + + private fun Mark.isValid(): Boolean = sourceIsAlive() && targetIsAlive() + + private fun Mark.sourceIsAlive(): Boolean = + source.isSlotAssigned && source.uid.packed == sourceUid && source.hitpoints > 0 + + private fun Mark.targetIsAlive(): Boolean = + target.isSlotAssigned && target.uidPacked() == targetUid && target.hitpoints() > 0 + + private class Mark( + val source: Player, + val sourceUid: Int, + val target: PathingEntity, + val targetUid: Int, + val targetBaseHitpoints: Int, + val playerDamage: Int, + val playerHealCap: Int, + val playerHitType: HitType, + var cancelled: Boolean = false, + ) + + private companion object { + const val BLOOD_SACRIFICE_DAMAGE = 25 + const val BLOOD_SACRIFICE_DELAY = 8 + const val BLOOD_SACRIFICE_ESCAPE_DISTANCE = 5 + const val TARGET_HIT_DELAY = 1 + const val NPC_HEAL_CAP = 25 + const val PLAYER_HEAL_CAP = 15 + } +} + +/** + * Pure Blood Sacrifice heal math, kept separate from [AncientGodswordBloodSacrifice] so it's + * testable without the world-queue/mark plumbing: the heal is 15% of the target's base hitpoints + * (25 flat for NPC targets, whose base HP is usually far higher), capped further by the damage + * that was actually dealt. + */ +internal object BloodSacrificeHeal { + fun healAmount(damage: Int, targetBaseHitpoints: Int, healCap: Int): Int { + val percentCap = targetBaseHitpoints * 15 / 100 + val maximum = min(percentCap, healCap) + return min(damage, maximum).coerceAtLeast(0) + } +} + +/** Registers the teleport safeguard without requiring a SpecialAttackModule change. */ +class AncientGodswordBloodSacrificeScript +@Inject +constructor(private val sacrifice: AncientGodswordBloodSacrifice) : PluginScript() { + override fun ScriptContext.startup() { + onEvent { sacrifice.cancelTeleportedTargets() } + } +} + +private fun PathingEntity.baseHitpoints(): Int = + when (this) { + is Npc -> baseHitpointsLvl + is Player -> baseHitpointsLvl + } + +private fun PathingEntity.hitpoints(): Int = + when (this) { + is Npc -> hitpoints + is Player -> hitpoints + } + +private fun PathingEntity.uidPacked(): Int = + when (this) { + is Npc -> uid.packed + is Player -> uid.packed + } diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientMaceSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientMaceSpecialAttack.kt new file mode 100644 index 000000000..75d12af62 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientMaceSpecialAttack.kt @@ -0,0 +1,109 @@ +package org.rsmod.content.other.special.attacks.melee + +import kotlin.math.max +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.player.disablePrayers +import org.rsmod.api.player.hit.modifier.BypassProtectionPrayerPlayerHitModifier +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.prayerLvl +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statAdd +import org.rsmod.api.player.stat.statBase +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Favour of the War God uses a normal melee roll, but ignores a player's Protect from Melee. + * Its prayer effects use the successful raw damage roll: this deliberately permits restoration + * from attacks on otherwise damage-immune NPCs, matching the live game's behaviour. + */ +class AncientMaceSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.ancient_goblin_mace", FavourOfTheWarGod(manager)) + } + + private class FavourOfTheWarGod(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + favourOfTheWarGod(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + favourOfTheWarGod(target, attack) + return true + } + + private fun ProtectedAccess.favourOfTheWarGod( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.slice_player_mace_special_attack") + spotanim( + spot = "spotanim.slice_player_mace_special_attack_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val rawDamage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + ) + val source = player + manager.giveCombatXp(this, target, attack, rawDamage) + manager.queueMeleeHit( + source = this, + target = target, + damage = rawDamage, + modifier = BypassProtectionPrayerPlayerHitModifier, + ) + // Real OSRS doesn't clamp damage after the roll, so the already-known pre-clamp + // rawDamage is the authentic value for both prayer effects - no impact callback + // needed to observe it after the fact. + if (rawDamage > 0) { + restorePrayer(source, rawDamage) + (target as? Player)?.let { targetPlayer -> drainPrayer(targetPlayer, rawDamage) } + } + manager.continueCombat(this, target) + } + + private fun restorePrayer(source: Player, damage: Int) { + val current = source.stat("stat.prayer") + val base = source.statBase("stat.prayer") + val restored = + if (current < base) { + current + damage + } else { + max(current, base + damage) + } + val added = restored - current + if (added > 0) { + source.statAdd("stat.prayer", constant = added, percent = 0) + } + } + + private fun drainPrayer(target: Player, damage: Int) { + target.statSub("stat.prayer", constant = damage, percent = 0) + if (target.prayerLvl == 0) { + target.rebuildAppearance() + target.disablePrayers() + } + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AreaMeleeTargetSelector.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AreaMeleeTargetSelector.kt new file mode 100644 index 000000000..ad1ebf261 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AreaMeleeTargetSelector.kt @@ -0,0 +1,130 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.death.NpcAttackValidateHook +import org.rsmod.api.death.NpcAttackValidateResult +import org.rsmod.api.npc.isValidTarget +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.repo.player.PlayerRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.interact.InteractionOp +import org.rsmod.game.map.CardinalDirection +import org.rsmod.game.map.Direction +import org.rsmod.map.CoordGrid +import org.rsmod.map.zone.ZoneKey + +/** + * Selects valid nearby targets on the exact tiles occupied by a melee area special. + * + * The primary target is always preserved. Secondary NPCs and players are capped independently, + * matching the cache-era weapon rules. + */ +class AreaMeleeTargetSelector +@Inject +constructor( + private val npcs: NpcRepository, + private val players: PlayerRepository, + private val npcAttackValidateHooks: Set, + private val pvp: PvPAreaAttackManager, +) { + fun square(centre: CoordGrid, radius: Int): Set { + require(radius >= 0) + val tiles = mutableSetOf() + for (x in -radius..radius) { + for (z in -radius..radius) { + tiles += centre.translate(x, z) + } + } + return tiles + } + + /** + * Builds the three-tile-wide line through the primary target that a halberd sweep affects. + */ + fun halberdSweep(source: Player, primary: PathingEntity): Set { + val centre = primary.coords + return when (Direction.cardinalBetween(source.bounds(), primary.bounds())) { + CardinalDirection.North, CardinalDirection.South -> + setOf(centre.translate(-1, 0), centre, centre.translate(1, 0)) + + CardinalDirection.East, CardinalDirection.West -> + setOf(centre.translate(0, -1), centre, centre.translate(0, 1)) + } + } + + fun select( + source: ProtectedAccess, + primary: PathingEntity, + tiles: Set, + npcLimit: Int, + playerLimit: Int, + totalLimit: Int = npcLimit + playerLimit, + searchZoneRadius: Int = DEFAULT_SEARCH_ZONE_RADIUS, + ): List { + require(npcLimit > 0) + require(playerLimit > 0) + require(totalLimit > 0) + require(searchZoneRadius >= 0) + + val targets = mutableListOf(primary) + var npcCount = if (primary is Npc) 1 else 0 + var playerCount = if (primary is Player) 1 else 0 + val zone = ZoneKey.from(primary.coords) + + for (npc in npcs.findAll(zone, zoneRadius = searchZoneRadius)) { + if (targets.size >= totalLimit || npcCount >= npcLimit) { + break + } + if (npc === primary || !npc.occupies(tiles) || !canAttack(source.player, npc)) { + continue + } + targets += npc + npcCount++ + } + + for (player in players.findAll(zone, zoneRadius = searchZoneRadius)) { + if (targets.size >= totalLimit || playerCount >= playerLimit) { + break + } + if ( + player === source.player || + player === primary || + !player.occupies(tiles) || + !pvp.canAttack(source.player, player) + ) { + continue + } + targets += player + playerCount++ + } + return targets + } + + private fun canAttack(source: Player, target: Npc): Boolean { + for (hook in npcAttackValidateHooks) { + if (hook.validate(source, target) is NpcAttackValidateResult.Deny) { + return false + } + } + if (!target.isValidTarget()) { + return false + } + return target.visType.hasOp(InteractionOp.Op2.slot) + } + + private fun PathingEntity.occupies(tiles: Set): Boolean = + bounds().asSequence().any { it in tiles } + + private companion object { + /** + * All affected tiles are at most two tiles from the source due to the weapon's attack + * range, but a radius of one also safely covers a zone boundary. Larger specials can + * opt into their exact search radius. + */ + private const val DEFAULT_SEARCH_ZONE_RADIUS: Int = 1 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArkanBladeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArkanBladeSpecialAttack.kt new file mode 100644 index 000000000..4a588b536 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArkanBladeSpecialAttack.kt @@ -0,0 +1,93 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.mechanics.toxins.BurnEffectService +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Flames of Ralos makes one 150%-accurate, 150%-maximum hit against Slash defence. A successful + * accuracy roll applies the shared Burn status, even if a later target-specific damage modifier + * reduces the direct hit to zero. + */ +class ArkanBladeSpecialAttack +@Inject +constructor( + private val burns: BurnEffectService, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.arkan_blade", FlamesOfRalos(manager, burns)) + } + + private class FlamesOfRalos( + private val manager: SpecialAttackManager, + private val burns: BurnEffectService, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + flamesOfRalos(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + flamesOfRalos(target, attack) + return true + } + + private fun ProtectedAccess.flamesOfRalos( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.vmq4_arkan_blade_special") + spotanim( + spot = "spotanim.vmq4_arkan_blade_special_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Slash, + multiplier = 1.5, + ) + val damage = + if (successful) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.5, + ) + } else { + 0 + } + + if (successful) { + burns.apply(source = player, target = target) + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArmadylGodswordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArmadylGodswordSpecialAttack.kt new file mode 100644 index 000000000..8d881a00e --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArmadylGodswordSpecialAttack.kt @@ -0,0 +1,86 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** The Armadyl godsword's standard special: a single accurate, high-damage Slash hit. */ +class ArmadylGodswordSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val standard = ArmadylGodsword(manager, "seq.ags_special_player", STANDARD_SPOTANIM) + registerMelee("obj.ags", standard) + registerMelee("obj.br_ags", standard) + registerMelee("obj.deadman_ags", standard) + registerMelee( + "obj.agsg", + ArmadylGodsword(manager, "seq.ags_special_ornate_player", ORNATE_SPOTANIM), + ) + registerMelee( + "obj.deadman_blighted_ags", + ArmadylGodsword(manager, "seq.ags_special_blighted_player", STANDARD_SPOTANIM), + ) + } + + private class ArmadylGodsword( + private val manager: SpecialAttackManager, + private val sequence: String, + private val graphic: String, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + smash(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + smash(target, attack) + return true + } + + private fun ProtectedAccess.smash(target: PathingEntity, attack: CombatAttack.Melee) { + anim(sequence) + // Wiki-confirmed sound id (shared with Saradomin sword's own lightning). Unaliased + // in this cache's gamevals. + soundSynth(THE_JUDGEMENT_SOUND) + target.spotanim( + spot = graphic, + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 2.0, + maxHitMultiplier = 1.375, + blockType = MeleeAttackType.Slash, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + } + + private companion object { + const val STANDARD_SPOTANIM = "spotanim.dh_sword_update_armadyl_special_spotanim" + const val ORNATE_SPOTANIM = "spotanim.armadyl_special_spotanim_gold" + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val THE_JUDGEMENT_SOUND = 3869 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BarrelchestAnchorSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BarrelchestAnchorSpecialAttack.kt new file mode 100644 index 000000000..36f3ad2b0 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BarrelchestAnchorSpecialAttack.kt @@ -0,0 +1,124 @@ +package org.rsmod.content.other.special.attacks.melee + +import kotlin.math.min +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Sunder doubles accuracy and drains one tenth of its unmodified damage roll through the target's + * Defence, Attack, Ranged, and Magic levels. + */ +class BarrelchestAnchorSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val sunder = Sunder(manager, maxHitMultiplier = 1.1) + val imbuedSunder = Sunder(manager, maxHitMultiplier = 1.25) + + registerMelee("obj.brain_anchor", sunder) + registerMelee("obj.bh_brain_anchor_imbue", imbuedSunder) + } + + private class Sunder( + private val manager: SpecialAttackManager, + private val maxHitMultiplier: Double, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + sunder(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + sunder(target, attack) + return true + } + + private fun ProtectedAccess.sunder( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.brain_player_anchor_special_attack") + soundSynth(SUNDER_SOUND) + spotanim( + spot = "spotanim.brain_anchor_special_attack_spot", + height = 0, + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 2.0, + maxHitMultiplier = maxHitMultiplier, + ) + // The drain is determined from the raw damage roll, before target-side modifiers. + val drain = damage / 10 + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value to gate the drain on - no impact callback needed. + if (damage > 0 && drain > 0) { + drainSunderStats(target, drain) + } + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SUNDER_SOUND = 3481 + } +} + +private fun drainSunderStats(target: PathingEntity, amount: Int) { + var remaining = amount + when (target) { + is Player -> { + for (stat in SUNDER_PLAYER_STAT_ORDER) { + val drain = min(target.stat(stat), remaining) + if (drain > 0) { + target.statSub(stat, constant = drain, percent = 0) + remaining -= drain + } + if (remaining == 0) { + return + } + } + } + is Npc -> { + fun drain(current: Int, apply: (Int) -> Unit) { + val value = min(current, remaining) + if (value > 0) { + apply(value) + remaining -= value + } + } + + drain(target.defenceLvl) { target.defenceLvl -= it } + drain(target.attackLvl) { target.attackLvl -= it } + drain(target.rangedLvl) { target.rangedLvl -= it } + drain(target.magicLvl) { target.magicLvl -= it } + } + } +} + +private val SUNDER_PLAYER_STAT_ORDER = + listOf( + "stat.defence", + "stat.attack", + "stat.ranged", + "stat.magic", + ) diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearSpecialAttack.kt new file mode 100644 index 000000000..973ff27c0 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearSpecialAttack.kt @@ -0,0 +1,132 @@ +package org.rsmod.content.other.special.attacks.melee + +import dev.openrune.util.Wearpos +import jakarta.inject.Inject +import kotlin.math.min +import org.rsmod.api.combat.commons.BindEffectService +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.styles.MeleeAttackStyle +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.inv.isAnyType + +/** + * Break Shackles uses any active binding duration on the target to boost its normal melee hit, + * then breaks that binding only after a positive final hit lands. + * + * Reads/breaks binds via [BindEffectService] - the shared tracker also used by Zamorak godsword's + * Ice Cleave (see `ImpactMeleeSpecialAttacks.kt`). Deliberately separate from the pre-existing + * magic freeze-spell mechanic (Snare/Entangle/Bind/Ice Barrage): a target frozen by a spell won't + * show a remaining bind duration here. Documented scope limit, not an oversight. + */ +class BlueMoonSpearSpecialAttack +@Inject +constructor(private val binds: BindEffectService) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val breakShackles = BreakShackles(manager, binds) + registerMelee("obj.frostmoon_spear", breakShackles) + registerMelee("obj.br_frostmoon_spear", breakShackles) + } + + private class BreakShackles( + private val manager: SpecialAttackManager, + private val binds: BindEffectService, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = breakShackles(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = breakShackles(target, attack) + + private fun ProtectedAccess.breakShackles( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + if (!player.isWearingBlueMoonSet()) { + mes("You need to be wearing the full Blue Moon armour set to use this special attack.") + manager.stopCombat(this) + return false + } + + val multipliers = BlueMoonSpearDamage.multipliers(binds.cyclesRemaining(target)) + anim("seq.human_zamorakspear_lunge") + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = multipliers.accuracy, + maxHitMultiplier = multipliers.maxHit, + ) + + // Break Shackles always grants Strength XP, independently of the selected spear style. + manager.giveCombatXp( + source = this, + target = target, + attack = attack.copy(style = MeleeAttackStyle.Aggressive), + damage = damage, + ) + manager.queueMeleeHit(source = this, target = target, damage = damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value - no impact callback needed to gate the bind break. + if (damage > 0) { + binds.breakBind(target) + } + manager.continueCombat(this, target) + return true + } + } +} + +/** Pure duration-to-damage conversion used by Break Shackles. */ +internal object BlueMoonSpearDamage { + data class Multipliers( + val accuracy: Double, + val maxHit: Double, + ) + + fun multipliers(bindingCycles: Int): Multipliers { + require(bindingCycles >= 0) { + "bindingCycles must not be negative. (bindingCycles=$bindingCycles)" + } + val bonus = bindingCycles * BONUS_PER_BIND_CYCLE + return Multipliers( + accuracy = 1.0 + bonus, + maxHit = 1.0 + min(bonus, MAX_DAMAGE_BONUS), + ) + } + + private const val BONUS_PER_BIND_CYCLE: Double = 0.015 + private const val MAX_DAMAGE_BONUS: Double = 1.125 +} + +private fun Player.isWearingBlueMoonSet(): Boolean = + worn[Wearpos.Hat.slot].isAnyType( + "obj.frost_moon_helm", + "obj.frost_moon_helm_degraded", + "obj.br_frost_moon_helm", + ) && + worn[Wearpos.Torso.slot].isAnyType( + "obj.frost_moon_chestplate", + "obj.frost_moon_chestplate_degraded", + "obj.br_frost_moon_chestplate", + ) && + worn[Wearpos.Legs.slot].isAnyType( + "obj.frost_moon_tassets", + "obj.frost_moon_tassets_degraded", + "obj.br_frost_moon_tassets", + ) && + worn[Wearpos.RightHand.slot].isAnyType( + "obj.frostmoon_spear", + "obj.br_frostmoon_spear", + ) diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerSpecialAttack.kt new file mode 100644 index 000000000..a82b1b583 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerSpecialAttack.kt @@ -0,0 +1,121 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statBase +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Backstab makes a normal-damage strike that cannot miss when this player was not the target's + * most recent source of positive final damage. A landed hit drains the final damage dealt from + * Defence, but only while the target has not already been drained below its base Defence level. + */ +class BoneDaggerSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val backstab = Backstab(manager) + registerMelee("obj.dttd_bone_dagger", backstab) + registerMelee("obj.dttd_bone_dagger_p", backstab) + registerMelee("obj.dttd_bone_dagger_p+", backstab) + registerMelee("obj.dttd_bone_dagger_p++", backstab) + } + + private class Backstab(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + backstab(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + backstab(target, attack) + return true + } + + private fun ProtectedAccess.backstab( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.dttd_player_stab_bone_dagger") + spotanim( + spot = "spotanim.dttd_dagger_sp_attack_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val successful = + BoneDaggerBackstab.isUnsuspecting( + lastDamagingPlayerUuid = target.lastDamagingPlayerUuid, + sourceUuid = player.uuid, + ) || + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Stab, + multiplier = 1.0, + ) + val damage = + if (successful) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + } else { + 0 + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(source = this, target = target, damage = damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known pre-clamp + // damage is the authentic value for the Defence drain - no impact callback needed. + if (damage > 0) { + reduceBoneDaggerDefence(target, damage) + } + manager.continueCombat(this, target) + } + } +} + +internal object BoneDaggerBackstab { + fun isUnsuspecting( + lastDamagingPlayerUuid: Long?, + sourceUuid: Long?, + ): Boolean = sourceUuid == null || lastDamagingPlayerUuid != sourceUuid +} + +private fun reduceBoneDaggerDefence( + target: PathingEntity, + damage: Int, +) { + when (target) { + is Player -> { + if (target.stat("stat.defence") >= target.statBase("stat.defence")) { + target.statSub("stat.defence", constant = damage, percent = 0) + } + } + is Npc -> { + if (target.defenceLvl >= target.baseDefenceLvl) { + target.defenceLvl = (target.defenceLvl - damage).coerceAtLeast(0) + } + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreSpecialAttack.kt new file mode 100644 index 000000000..6ad663be7 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreSpecialAttack.kt @@ -0,0 +1,153 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statAdd +import org.rsmod.api.player.stat.statBase +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.map.CoordGrid + +/** + * Liquify is an underwater-only attack with double accuracy. On a positive final hit, it raises + * the user's Attack, Strength, and Defence by 25% of the damage dealt, capped at + * base level + 3 + 10% of the base level for each stat. + */ +class BrineSabreSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.olaf2_brine_sabre", Liquify(manager)) + } + + private class Liquify(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = liquify(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = liquify(target, attack) + + private fun ProtectedAccess.liquify( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + if (!BrineSabreUnderwaterAreas.contains(coords)) { + mes("You can only use this special attack underwater.") + return false + } + + anim("seq.olaf2_brine_sabre_special") + // Wiki-sourced ("brain_special_brine_saber"), unaliased in this cache's gamevals. + soundSynth(LIQUIFY_SOUND) + spotanim( + spot = "spotanim.olaf2_brine_sabre_special_spot", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 2.0, + maxHitMultiplier = 1.0, + blockType = MeleeAttackType.Stab, + ) + val source = player + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value for the stat boost - no impact callback needed. + if (damage > 0) { + boostLiquifyStats(source, damage) + } + manager.continueCombat(this, target) + return true + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val LIQUIFY_SOUND = 3473 + } +} + +private fun boostLiquifyStats( + source: Player, + damage: Int, +) { + for (stat in BrineSabreLiquify.BOOSTED_STATS) { + val added = + BrineSabreLiquify.levelsToAdd( + base = source.statBase(stat), + current = source.stat(stat), + damage = damage, + ) + if (added > 0) { + source.statAdd(stat, constant = added, percent = 0) + } + } +} + +internal object BrineSabreLiquify { + internal val BOOSTED_STATS = + listOf( + "stat.attack", + "stat.strength", + "stat.defence", + ) + + fun levelsToAdd( + base: Int, + current: Int, + damage: Int, + ): Int { + val damageBoost = damage / 4 + if (damageBoost <= 0) { + return 0 + } + val cap = base + 3 + (base / 10) + return (minOf(cap, current + damageBoost) - current).coerceAtLeast(0) + } +} + +/** + * The cache does not expose a general-purpose underwater area flag, so this remains deliberately + * isolated instead of making Liquify usable everywhere. It covers the revision's mapped static + * underwater regions (Mogre Camp, Harmony Island's underwater tunnel, and Fossil Island's + * underwater area). The Rum Deal 'Rum'-geon instance is not present in OpenRune's map + * configuration; it is intentionally not guessed here and should be added when that instance is + * loaded. + */ +internal object BrineSabreUnderwaterAreas { + fun contains(coords: CoordGrid): Boolean { + val regionX = coords.x ushr 6 + val regionZ = coords.z ushr 6 + return when (coords.level) { + 0 -> regionZ == FOSSIL_ISLAND_REGION_Z && regionX in FOSSIL_ISLAND_REGION_X + 1 -> + (regionX == MOGRE_CAMP_REGION_X && regionZ == MOGRE_CAMP_REGION_Z) || + (regionX == HARMONY_TUNNEL_REGION_X && regionZ == HARMONY_TUNNEL_REGION_Z) || + (regionZ == FOSSIL_ISLAND_REGION_Z && regionX in FOSSIL_ISLAND_REGION_X) + else -> false + } + } + + private const val MOGRE_CAMP_REGION_X: Int = 46 + private const val MOGRE_CAMP_REGION_Z: Int = 148 + private const val HARMONY_TUNNEL_REGION_X: Int = 59 + private const val HARMONY_TUNNEL_REGION_Z: Int = 144 + private val FOSSIL_ISLAND_REGION_X: IntRange = 58..59 + private const val FOSSIL_ISLAND_REGION_Z: Int = 160 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawsSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawsSpecialAttack.kt new file mode 100644 index 000000000..9e33458dd --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawsSpecialAttack.kt @@ -0,0 +1,193 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.mechanics.toxins.BurnEffectService +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Burning Barrage makes up to three accuracy rolls. The first successful roll controls both the + * total-damage range and the independent burn chance of all three hitsplats. + */ +class BurningClawsSpecialAttack +@Inject +constructor( + private val burns: BurnEffectService, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val barrage = BurningBarrage(manager, burns) + // The live cache retains the original "bone claws" aliases for both standard and BR items. + registerMelee("obj.bone_claws", barrage) + registerMelee("obj.br_bone_claws", barrage) + } + + private class BurningBarrage( + private val manager: SpecialAttackManager, + private val burns: BurnEffectService, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + burningBarrage(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + burningBarrage(target, attack) + return true + } + + private fun ProtectedAccess.burningBarrage( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.human_weapon_burning_claws_02_spec") + // Wiki-sourced ("burning_claws_swipe_01"), unaliased in this cache's gamevals. + soundSynth(BURNING_BARRAGE_SOUND) + spotanim( + spot = "spotanim.vfx_burning_claws_spec_02", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val result = + BurningClawDamage.roll( + maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ), + rollAccuracy = { + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + // Burning claws use the selected Slash or Stab attack style. + blockType = attack.type, + multiplier = 1.0, + ) + }, + rollInclusive = { range -> random.of(range) }, + rollExclusive = { maximum -> random.of(maximum) }, + ) + + manager.giveCombatXp(this, target, attack, result.hits.sum()) + result.hits.forEach { hit -> manager.queueMeleeHit(this, target, hit) } + + // The three chances are independent and apply to successful hitsplats, including a + // guaranteed zero-value hitsplat in the second and third accuracy tiers. + repeat(BurningClawDamage.HIT_COUNT) { + if (random.of(100) < result.burnChancePercent) { + burns.apply(source = player, target = target) + } + } + + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val BURNING_BARRAGE_SOUND = 9316 + } +} + +/** + * Exact Burning Barrage damage construction. Percentage operations and all individual hitsplats + * are intentionally truncated, matching the live special attack. + */ +internal object BurningClawDamage { + internal const val HIT_COUNT: Int = 3 + + internal data class Result( + val hits: IntArray, + val burnChancePercent: Int, + ) + + /** + * Evaluates accuracy until the first success. A successful tier produces all three hitsplats + * and gives each one the tier's independent burn chance. + */ + fun roll( + maxHit: Int, + rollAccuracy: () -> Boolean, + rollInclusive: (IntRange) -> Int, + rollExclusive: (Int) -> Int, + ): Result { + for (tier in 0 until HIT_COUNT) { + if (!rollAccuracy()) { + continue + } + + val total = roll(range(maxHit.coerceAtLeast(0), tier), rollInclusive) + return Result( + hits = split(total, tier), + burnChancePercent = burnChance(tier), + ) + } + return Result(hits = misses(rollExclusive), burnChancePercent = 0) + } + + private fun range(maxHit: Int, tier: Int): IntRange = + when (tier) { + 0 -> (maxHit * 3 / 4)..(maxHit * 7 / 4) + 1 -> (maxHit / 2)..(maxHit * 3 / 2) + 2 -> (maxHit / 4)..(maxHit * 5 / 4) + else -> error("Expected an accuracy tier in [0, 2], got $tier") + } + + private fun roll( + range: IntRange, + rollInclusive: (IntRange) -> Int, + ): Int = + if (range.first == range.last) { + range.first + } else { + rollInclusive(range) + } + + private fun split(total: Int, tier: Int): IntArray = + when (tier) { + 0 -> intArrayOf(total / 4, total / 4, total / 2) + 1 -> + intArrayOf( + (total / 2 - 1).coerceAtLeast(0), + (total / 2 - 1).coerceAtLeast(0), + 2, + ) + 2 -> intArrayOf(1, 1, (total - 2).coerceAtLeast(0)) + else -> error("Expected an accuracy tier in [0, 2], got $tier") + } + + private fun burnChance(tier: Int): Int = + when (tier) { + 0 -> 15 + 1 -> 30 + 2 -> 45 + else -> error("Expected an accuracy tier in [0, 2], got $tier") + } + + private fun misses(rollExclusive: (Int) -> Int): IntArray = + when (rollExclusive(5)) { + 0 -> intArrayOf(0, 0, 0) + 1, 2 -> intArrayOf(0, 0, 1) + 3, 4 -> intArrayOf(0, 0, 2) + else -> error("Random source returned a value outside [0, 4]") + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrimsonKistenSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrimsonKistenSpecialAttack.kt new file mode 100644 index 000000000..fb8668ee6 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrimsonKistenSpecialAttack.kt @@ -0,0 +1,125 @@ +package org.rsmod.content.other.special.attacks.melee + +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Brutal Swing: 4 accuracy rolls in one hit, minimum/maximum both raised 20% per additional + * success (70-110% at 1 success, up to 130-170% at all 4). The cache defines no special-energy + * param for this weapon, so the 50% cost is provided directly instead of read from cache data. + */ +public class CrimsonKistenSpecialAttack @Inject constructor(private val random: GameRandom) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee( + specWeapon = CRIMSON_KISTEN_ITEM_ID, + energyInHundreds = CRIMSON_KISTEN_SPECIAL_ENERGY, + special = BrutalSwing(manager, random), + ) + } + + private class BrutalSwing( + private val manager: SpecialAttackManager, + private val random: GameRandom, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = brutalSwing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = brutalSwing(target, attack) + + private fun ProtectedAccess.brutalSwing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + if (player.righthand?.id != CRIMSON_KISTEN_ITEM_ID || + attack.weapon?.id != CRIMSON_KISTEN_ITEM_ID + ) { + return false + } + + player.anim(CRIMSON_KISTEN_SPECIAL_SEQUENCE) + player.spotanim( + spot = CRIMSON_KISTEN_SPECIAL_SPOTANIM, + slot = constants.spotanim_slot_combat, + ) + + val successes = + (1..4).count { + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = MeleeAttackType.Crush, + attackStyle = attack.style, + blockType = MeleeAttackType.Crush, + multiplier = 1.0, + ) + } + val damage = + when (successes) { + 0 -> 0 + else -> { + val normalMax = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = MeleeAttackType.Crush, + attackStyle = attack.style, + multiplier = 1.0, + ) + val range = BrutalSwingDamage.range(normalMax, successes) + if (range.first == range.last) range.first else random.of(range) + } + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + return true + } + } + + private companion object { + val CRIMSON_KISTEN_ITEM_ID = "obj.crimson_kisten".asRSCM(RSCMType.OBJ) + const val CRIMSON_KISTEN_SPECIAL_ENERGY = 500 + const val CRIMSON_KISTEN_SPECIAL_SEQUENCE = "seq.human_weapons_crimson_kisten_special" + const val CRIMSON_KISTEN_SPECIAL_SPOTANIM = "spotanim.vfx_crimson_kisten_special" + } +} + +internal object BrutalSwingDamage { + fun range(normalMax: Int, successfulRolls: Int): IntRange { + require(successfulRolls in 1..4) { "Expected successful rolls in [1..4]: $successfulRolls" } + val (minimumPercent, maximumPercent) = + when (successfulRolls) { + 1 -> 70 to 110 + 2 -> 90 to 130 + 3 -> 110 to 150 + 4 -> 130 to 170 + else -> error("Unreachable") + } + val minimum = normalMax.percentOf(minimumPercent) + val maximum = (normalMax.percentOf(maximumPercent) - 1).coerceAtLeast(minimum) + return minimum..maximum + } + + private fun Int.percentOf(percent: Int): Int = (toLong() * percent / 100L).toInt() +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrystalHalberdSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrystalHalberdSpecialAttack.kt new file mode 100644 index 000000000..e60bcb64b --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrystalHalberdSpecialAttack.kt @@ -0,0 +1,122 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.map.CoordGrid + +/** + * Sweep: 110% maximum damage against Slash defence. Large NPCs receive a second independent hit + * with 75% accuracy; otherwise, multi-combat sweeps the three-tile-wide line through the target. + */ +class CrystalHalberdSpecialAttack +@Inject +constructor( + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val sweep = Sweep(manager, targets, pvp, worldRepo) + registerMelee("obj.crystal_halberd", sweep) + registerMelee("obj.crystal_halberd_2500", sweep) + } + + private class Sweep( + private val manager: SpecialAttackManager, + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = sweep(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = sweep(target, attack) + + private fun ProtectedAccess.sweep( + primary: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + anim("seq.dragon_halberd_special_attack") + // Ground-tile graphic, not attached to the caster: target's own tile for a + // single-tile target, otherwise the midpoint between target and caster. + val tile = + if (primary.size == 1) { + primary.coords + } else { + val centre = primary.bounds() + CoordGrid( + x = ((centre.fineCentreX + player.coords.x) / 2.0).toInt(), + z = ((centre.fineCentreZ + player.coords.z) / 2.0).toInt(), + level = player.coords.level, + ) + } + spotanimMap( + repo = worldRepo, + internal = HalberdSpecialVisuals.forTarget(player.coords, tile, color = "white"), + coord = tile, + height = 96, + ) + + val affected = + if (mapMultiway() && primary.size == 1) { + targets.select( + source = this, + primary = primary, + tiles = targets.halberdSweep(player, primary), + npcLimit = MAX_NPC_TARGETS, + playerLimit = MAX_PLAYER_TARGETS, + ) + } else { + listOf(primary) + } + + for (target in affected) { + val hitCount = if (target === primary && target is Npc && target.size > 1) 2 else 1 + var totalDamage = 0 + repeat(hitCount) { hit -> + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = if (hit == 0) 1.0 else SECOND_HIT_ACCURACY, + maxHitMultiplier = MAX_HIT_MULTIPLIER, + blockType = MeleeAttackType.Slash, + ) + totalDamage += damage + manager.queueMeleeHit(this, target, damage) + } + manager.giveCombatXp(this, target, attack, totalDamage) + + if (target is Player && target !== primary) { + pvp.applySecondarySpecialAttack(this, target) + } + } + manager.continueCombat(this, primary) + return true + } + + private companion object { + private const val MAX_NPC_TARGETS: Int = 10 + private const val MAX_PLAYER_TARGETS: Int = 3 + private const val MAX_HIT_MULTIPLIER: Double = 1.1 + private const val SECOND_HIT_ACCURACY: Double = 0.75 + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DemonbaneSpecialAttacks.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DemonbaneSpecialAttacks.kt new file mode 100644 index 000000000..4a48a0353 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DemonbaneSpecialAttacks.kt @@ -0,0 +1,129 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Darklight, Arclight, and Emberlight share Weaken. It makes a normal melee hit against Stab + * defence and, only when the accuracy roll succeeds, drains Attack, Strength, and Defence by a + * base-level percentage plus one. The drain stacks additively. + */ +class DemonbaneSpecialAttacks : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val darklight = Weaken(manager, demonDrainPercent = 10) + registerMelee("obj.darklight", darklight) + + val arclight = Weaken(manager, demonDrainPercent = 10) + registerMelee("obj.arclight", arclight) + registerMelee("obj.arclight_inactive", arclight) + + // Only Emberlight's own wiki page documents a "Sound effects" table for this special; + // Darklight and Arclight have none, so they get no sound here rather than a guess. + val emberlight = Weaken(manager, demonDrainPercent = 15, sounds = EMBERLIGHT_SOUNDS) + registerMelee("obj.emberlight", emberlight) + } + + private inner class Weaken( + private val manager: SpecialAttackManager, + private val demonDrainPercent: Int, + private val sounds: IntArray = IntArray(0), + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + weaken(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + weaken(target, attack) + return true + } + + private fun ProtectedAccess.weaken( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.dark_spec_player") + spotanim("spotanim.dark_spec_spot", slot = constants.spotanim_slot_combat) + sounds.forEachIndexed { index, sound -> soundSynth(sound, delay = index * SOUND_SPACING) } + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Stab, + multiplier = 1.0, + ) + val damage = + if (successful) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + } else { + 0 + } + val drainPercent = + if (target is Npc && target.visType.param(params.demon) != 0) { + demonDrainPercent + } else { + NORMAL_DRAIN_PERCENT + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + if (successful) { + drainCombatStats(target, drainPercent) + } + manager.continueCombat(this, target) + } + } + + private fun drainCombatStats(target: PathingEntity, percent: Int) { + when (target) { + is Player -> { + target.statSub("stat.attack", constant = 1, percent = percent) + target.statSub("stat.strength", constant = 1, percent = percent) + target.statSub("stat.defence", constant = 1, percent = percent) + } + is Npc -> { + target.attackLvl = (target.attackLvl - drainAmount(target.baseAttackLvl, percent)).coerceAtLeast(0) + target.strengthLvl = + (target.strengthLvl - drainAmount(target.baseStrengthLvl, percent)).coerceAtLeast(0) + target.defenceLvl = + (target.defenceLvl - drainAmount(target.baseDefenceLvl, percent)).coerceAtLeast(0) + } + } + } + + private fun drainAmount(baseLevel: Int, percent: Int): Int = (baseLevel * percent / 100) + 1 + + private companion object { + const val NORMAL_DRAIN_PERCENT: Int = 5 + const val SOUND_SPACING: Int = 20 + + // Emberlight's own wiki "Sound effects" table: crouch/spin/stab, one per special-attack + // part. + val EMBERLIGHT_SOUNDS = intArrayOf(9319, 9320, 9321) + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DinhsBulwarkSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DinhsBulwarkSpecialAttack.kt new file mode 100644 index 000000000..c25e71c86 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DinhsBulwarkSpecialAttack.kt @@ -0,0 +1,156 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.styles.MeleeAttackStyle +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Shield Bash: hits valid targets in the wielder's 11x11 surrounding area. NPC targets receive + * two independent hits while player targets receive one. Every resolved positive hit drains the + * target's highest current offensive stat by 5%, preferring melee (Attack + Strength), then + * Ranged, then Magic when values are tied. + */ +class DinhsBulwarkSpecialAttack +@Inject +constructor( + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val shieldBash = ShieldBash(manager, targets, pvp) + registerMelee("obj.dinhs_bulwark", shieldBash) + registerMelee("obj.dinhs_bulwark_ornament", shieldBash) + } + + private class ShieldBash( + private val manager: SpecialAttackManager, + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = shieldBash(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = shieldBash(target, attack) + + private fun ProtectedAccess.shieldBash( + primary: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + anim("seq.human_dinhs_bulwark_bash") + + val affected = + if (mapMultiway()) { + targets.select( + source = this, + primary = primary, + tiles = targets.square(player.coords, radius = AREA_RADIUS), + npcLimit = MAX_TARGETS, + playerLimit = MAX_TARGETS, + totalLimit = MAX_TARGETS, + ) + } else { + listOf(primary) + } + + for (target in affected) { + val hitCount = if (target is Player) 1 else NPC_HIT_COUNT + var totalDamage = 0 + repeat(hitCount) { + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = + if (target is Npc && attack.style == MeleeAttackStyle.Defensive) { + DEFENSIVE_NPC_ACCURACY + } else { + ACCURACY_MULTIPLIER + }, + maxHitMultiplier = 1.0, + blockType = MeleeAttackType.Crush, + ) + totalDamage += damage + // Real OSRS doesn't clamp damage after the roll, so the already-known damage + // here is the authentic value - no impact callback needed to gate the drain. + manager.queueMeleeHit(source = this, target = target, damage = damage) + if (damage > 0) { + drainHighestOffence(target) + } + } + manager.giveCombatXp(this, target, attack, totalDamage) + + if (target is Player && target !== primary) { + pvp.applySecondarySpecialAttack(this, target) + } + } + manager.continueCombat(this, primary) + return true + } + } + + private companion object { + private const val AREA_RADIUS: Int = 5 + private const val MAX_TARGETS: Int = 10 + private const val NPC_HIT_COUNT: Int = 2 + private const val ACCURACY_MULTIPLIER: Double = 1.2 + private const val DEFENSIVE_NPC_ACCURACY: Double = 0.8 + private const val DRAIN_PERCENT: Int = 5 + } +} + +private fun drainHighestOffence(target: PathingEntity) { + when (target) { + is Player -> target.drainHighestOffence() + is Npc -> target.drainHighestOffence() + } +} + +private fun Player.drainHighestOffence() { + val attack = stat("stat.attack") + val strength = stat("stat.strength") + val ranged = stat("stat.ranged") + val magic = stat("stat.magic") + when { + attack + strength >= ranged && attack + strength >= magic -> { + statSub("stat.attack", constant = attack * DRAIN_PERCENT / 100, percent = 0) + statSub("stat.strength", constant = strength * DRAIN_PERCENT / 100, percent = 0) + } + ranged >= magic -> statSub("stat.ranged", constant = ranged * DRAIN_PERCENT / 100, percent = 0) + else -> statSub("stat.magic", constant = magic * DRAIN_PERCENT / 100, percent = 0) + } +} + +private fun Npc.drainHighestOffence() { + val attack = attackLvl + val strength = strengthLvl + val ranged = rangedLvl + val magic = magicLvl + when { + attack + strength >= ranged && attack + strength >= magic -> { + attackLvl = (attack - attack * DRAIN_PERCENT / 100).coerceAtLeast(0) + strengthLvl = (strength - strength * DRAIN_PERCENT / 100).coerceAtLeast(0) + } + ranged >= magic -> rangedLvl = (ranged - ranged * DRAIN_PERCENT / 100).coerceAtLeast(0) + else -> magicLvl = (magic - magic * DRAIN_PERCENT / 100).coerceAtLeast(0) + } +} + +private const val DRAIN_PERCENT: Int = 5 diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DogswordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DogswordSpecialAttack.kt new file mode 100644 index 000000000..2fe8d2fa2 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DogswordSpecialAttack.kt @@ -0,0 +1,193 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import kotlin.math.max +import kotlin.math.min +import org.rsmod.api.combat.commons.BindEffectService +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatEffects +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statHeal +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType + +/** + * Power of the Gods combines each godsword special into one doubled-accuracy, 37.5%-stronger + * Slash hit. The cache does not expose an RSCM alias for this event item's unique animation, so + * the handler intentionally avoids substituting another godsword's visual effect. + */ +class DogswordSpecialAttack +@Inject +constructor( + private val sacrifice: AncientGodswordBloodSacrifice, + private val binds: BindEffectService, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee( + "obj.echo_godsword", + PowerOfTheGods( + manager = manager, + sacrifice = sacrifice, + binds = binds, + bloodSacrificeDamage = LEAGUE_BLOOD_SACRIFICE_DAMAGE, + bloodSacrificeHealCap = LEAGUE_BLOOD_SACRIFICE_HEAL_CAP, + ), + ) + registerMelee( + "obj.deadman_dogsword", + PowerOfTheGods( + manager = manager, + sacrifice = sacrifice, + binds = binds, + bloodSacrificeDamage = DEADMAN_BLOOD_SACRIFICE_DAMAGE, + bloodSacrificeHealCap = DEADMAN_BLOOD_SACRIFICE_HEAL_CAP, + ), + ) + } + + private class PowerOfTheGods( + private val manager: SpecialAttackManager, + private val sacrifice: AncientGodswordBloodSacrifice, + private val binds: BindEffectService, + private val bloodSacrificeDamage: Int, + private val bloodSacrificeHealCap: Int, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = powerOfTheGods(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = powerOfTheGods(target, attack) + + private fun ProtectedAccess.powerOfTheGods( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + anim("seq.leagues_5_godsword_special") + spotanim( + spot = "spotanim.league_5_godsword_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = ACCURACY_MULTIPLIER, + maxHitMultiplier = MAX_HIT_MULTIPLIER, + blockType = MeleeAttackType.Slash, + ) + val source = player + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value for every effect below - no impact callback needed. + if (damage > 0) { + drainBandosStats(target, damage) + source.statHeal( + "stat.hitpoints", + constant = max(SARADOMIN_MINIMUM_HITPOINTS_HEAL, (damage + 1) / 2), + percent = 0, + ) + source.statHeal( + "stat.prayer", + constant = max(SARADOMIN_MINIMUM_PRAYER_HEAL, (damage + 3) / 4), + percent = 0, + ) + // Wiki: the Zamorak godsword's freeze is a real PvM tool (Muttadiles, Barrows' + // Dharok) - not player-only. Matches ImpactMeleeSpecialAttacks' ZamorakGodsword. + // Wiki: "Freezes opponent... with a similar animation to Ice Barrage." + target.spotanim("spotanim.ice_barrage_impact") + when (target) { + is Player -> CombatEffects.freeze(target, ZAMORAK_FREEZE_TICKS) + is Npc -> binds.bind(target, ZAMORAK_FREEZE_TICKS) + } + sacrifice.mark( + source = source, + target = target, + playerDamage = bloodSacrificeDamage, + playerHealCap = bloodSacrificeHealCap, + playerHitType = HitType.Magic, + ) + } + manager.continueCombat(this, target) + return true + } + } + + internal companion object { + const val ACCURACY_MULTIPLIER: Double = 2.0 + const val MAX_HIT_MULTIPLIER: Double = 1.375 + + const val SARADOMIN_MINIMUM_HITPOINTS_HEAL: Int = 10 + const val SARADOMIN_MINIMUM_PRAYER_HEAL: Int = 5 + const val ZAMORAK_FREEZE_TICKS: Int = 33 + + const val LEAGUE_BLOOD_SACRIFICE_DAMAGE: Int = 25 + const val LEAGUE_BLOOD_SACRIFICE_HEAL_CAP: Int = 25 + const val DEADMAN_BLOOD_SACRIFICE_DAMAGE: Int = 15 + const val DEADMAN_BLOOD_SACRIFICE_HEAL_CAP: Int = 10 + + val BANDOS_PLAYER_STAT_ORDER = + listOf( + "stat.defence", + "stat.strength", + "stat.prayer", + "stat.attack", + "stat.magic", + "stat.ranged", + ) + } +} + +/** + * Mirrors Bandos's drain order. This stays local because the dogsword combines the finished + * impacts rather than depending on a particular concrete godsword handler. + */ +private fun drainBandosStats(target: PathingEntity, amount: Int) { + var remaining = amount + when (target) { + is Player -> { + for (stat in DogswordSpecialAttack.BANDOS_PLAYER_STAT_ORDER) { + val drain = min(target.stat(stat), remaining) + if (drain > 0) { + target.statSub(stat, constant = drain, percent = 0) + remaining -= drain + } + if (remaining == 0) { + return + } + } + } + + is Npc -> { + fun drain(current: Int, apply: (Int) -> Unit) { + val value = min(current, remaining) + if (value > 0) { + apply(value) + remaining -= value + } + } + + drain(target.defenceLvl) { target.defenceLvl -= it } + drain(target.strengthLvl) { target.strengthLvl -= it } + drain(target.attackLvl) { target.attackLvl -= it } + drain(target.magicLvl) { target.magicLvl -= it } + drain(target.rangedLvl) { target.rangedLvl -= it } + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/Dragon2hSwordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/Dragon2hSwordSpecialAttack.kt new file mode 100644 index 000000000..79a3e66a8 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/Dragon2hSwordSpecialAttack.kt @@ -0,0 +1,96 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Powerstab: a normal-accuracy, normal-damage hit on every eligible target in the attacker's + * surrounding 3x3 area while in multi-combat. + */ +class Dragon2hSwordSpecialAttack +@Inject +constructor( + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val powerstab = Powerstab(manager, targets, pvp) + registerMelee("obj.dragon_2h_sword", powerstab) + registerMelee("obj.br_dragon_2h", powerstab) + registerMelee("obj.bh_dragon_2h_sword_corrupted", powerstab) + } + + private class Powerstab( + private val manager: SpecialAttackManager, + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = powerstab(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = powerstab(target, attack) + + private fun ProtectedAccess.powerstab( + primary: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + anim("seq.dragon_two_handed_sword") + soundSynth(POWERSTAB_SOUND) + spotanim("spotanim.dragon_two_handed_sword_blast") + + val affected = + if (mapMultiway()) { + targets.select( + source = this, + primary = primary, + tiles = targets.square(player.coords, radius = 1), + npcLimit = MAX_NPC_TARGETS, + playerLimit = MAX_PLAYER_TARGETS, + ) + } else { + listOf(primary) + } + + for (target in affected) { + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + + if (target is Player && target !== primary) { + pvp.applySecondarySpecialAttack(this, target) + } + } + manager.continueCombat(this, primary) + return true + } + + private companion object { + private const val MAX_NPC_TARGETS: Int = 14 + private const val MAX_PLAYER_TARGETS: Int = 3 + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + private const val POWERSTAB_SOUND = 2530 + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonCandleDaggerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonCandleDaggerSpecialAttack.kt new file mode 100644 index 000000000..21eb9e39c --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonCandleDaggerSpecialAttack.kt @@ -0,0 +1,48 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Celebrate is deliberately an attack that always deals zero damage. + * + * The game has a free-to-play world restriction for this cosmetic special. OpenRune's realm + * configuration has no membership-world state, so the combat behaviour is kept exact while that + * unavailable world gate is left to a future realm-membership implementation. + */ +class DragonCandleDaggerSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.osb10_dragon_candle", Celebrate(manager)) + } + + private class Celebrate(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = celebrate(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = celebrate(target, attack) + + private fun ProtectedAccess.celebrate( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + // This special never rolls accuracy or damage: Celebrate always produces a zero hit. + anim("seq.bday23_candle_special") + manager.giveCombatXp(this, target, attack, damage = 0) + manager.queueMeleeHit(this, target, damage = 0) + manager.continueCombat(this, target) + return true + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsSpecialAttack.kt new file mode 100644 index 000000000..032915bd6 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsSpecialAttack.kt @@ -0,0 +1,234 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +class DragonClawsSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.dragon_claws", DragonClaws(manager)) + registerMelee("obj.bh_dragon_claws_corrupted", DragonClaws(manager)) + registerMelee("obj.br_dragon_claws", DragonClaws(manager)) + registerMelee("obj.dragon_claws_ornament", DragonClaws(manager)) + registerMelee("obj.deadman_blighted_dragon_claws", DragonClaws(manager)) + } + + /** + * "Slice and Dice" - four hits in succession. Each hit rolls accuracy independently (same + * odds as a normal hit) until one connects; once a hit lands, every remaining hit is + * guaranteed to also land, with damage cascading down from whichever hit was first to + * connect. If all four accuracy rolls fail, there's still a small chance of "sympathy" + * damage. + * + * Damage ranges and chaining validated against every worked example on the wiki (e.g. + * 35-17-8-9, 0-30-15-16, 0-0-22-23, 0-0-0-46) and the four listed all-miss patterns. + * + * @see Dragon claws - Special attack + */ + private class DragonClaws(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + sliceAndDice(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + sliceAndDice(target, attack) + return true + } + + private fun ProtectedAccess.sliceAndDice(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.human_dragon_claws_spec") + spotanim( + spot = "spotanim.dragon_claws_spot", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + + val hits = rollHits(target, attack, maxHit) + + // One sound per swing beat (third/fourth share a clip), always played regardless of + // whether that beat's roll connected. All 4 calls land in the same server tick, so + // they need `delay` staggering - otherwise only the first of several same-tick synth + // calls actually plays client-side. + var totalDamage = 0 + for ((index, damage) in hits.withIndex()) { + totalDamage += damage + soundSynth(sliceAndDiceSound(index), delay = index * HIT_SOUND_SPACING) + manager.queueMeleeHit(this, target, damage) + } + manager.giveCombatXp(this, target, attack, totalDamage) + manager.continueCombat(this, target) + } + + private fun sliceAndDiceSound(hitIndex: Int): Int = + when (hitIndex) { + 0 -> DRAGONCLAWS_SPECIAL_1_SOUND + 1 -> DRAGONCLAWS_SPECIAL_2_SOUND + else -> DRAGONCLAWS_SPECIAL_3_SOUND + } + + /** + * Whichever hit is first to connect stands in for every hit that would have preceded it, + * so the earlier it lands, the wider its damage range. Everything after it is a + * guaranteed hit (no further accuracy rolls) that roughly halves each time, except the + * final hit in the chain, which gets a `+1` bump instead of being halved again. + */ + private fun ProtectedAccess.rollHits( + target: PathingEntity, + attack: CombatAttack.Melee, + maxHit: Int, + ): IntArray = + DragonClawsDamage.rollHits( + maxHit = maxHit, + rollAccuracy = { rollAccuracy(target, attack) }, + rollRange = { range -> rollRange(range.first, range.last) }, + rollSympathyTriggers = { random.of(3) < 2 }, + rollSympathyPattern = { random.of(4) }, + ) + + private fun ProtectedAccess.rollAccuracy( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Slash, + multiplier = 1.0, + ) + + /** + * Inclusive random range, safely handling a lower bound of `0` or below. Rolled directly + * with [random] rather than through `manager.rollMeleeDamage` (the multi-hit split has no + * single call that fits), so it has to check `adminMaxHit` itself - the same convention + * every `PlayerAttackManager` roll function uses (return the top of the range instead of + * rolling) - or `::maxhit` silently does nothing for this special. + */ + private fun ProtectedAccess.rollRange(min: Int, max: Int): Int = + DragonClawsDamage.resolveRange( + min = min, + max = max, + isMaxHit = player.adminMaxHit, + rollInclusive = { lo, hi -> random.of(lo, hi) }, + rollUpTo = { exclusiveBound -> random.of(maxExclusive = exclusiveBound) }, + ) + + private companion object { + // Wiki "Sound effects" table; unaliased in this cache's gamevals. + const val DRAGONCLAWS_SPECIAL_1_SOUND = 4138 + const val DRAGONCLAWS_SPECIAL_2_SOUND = 4140 + const val DRAGONCLAWS_SPECIAL_3_SOUND = 4141 + + /** Matches `StandardPlayerHitProcessor`'s hit-reaction sound delay convention. */ + const val HIT_SOUND_SPACING = 20 + } + } +} + +/** + * Pure damage distribution for Slice and Dice's four-hit cascade, kept separate from + * [ProtectedAccess] so the arithmetic can be unit tested directly against the wiki's worked + * examples instead of only through a live combat roll. + */ +internal object DragonClawsDamage { + fun rollHits( + maxHit: Int, + rollAccuracy: () -> Boolean, + rollRange: (IntRange) -> Int, + rollSympathyTriggers: () -> Boolean, + rollSympathyPattern: () -> Int, + ): IntArray { + val hits = IntArray(4) + when { + rollAccuracy() -> { + hits[0] = rollRange(maxHit / 2..maxHit - 1) + hits[1] = hits[0] / 2 + hits[2] = hits[1] / 2 + hits[3] = hits[2] + 1 + } + rollAccuracy() -> { + hits[1] = rollRange(((maxHit * 3) / 8)..((maxHit * 7) / 8)) + hits[2] = hits[1] / 2 + hits[3] = hits[2] + 1 + } + rollAccuracy() -> { + hits[2] = rollRange((maxHit / 4)..((maxHit * 3) / 4)) + hits[3] = hits[2] + 1 + } + rollAccuracy() -> { + hits[3] = rollRange((maxHit / 4)..((maxHit * 5) / 4)) + } + else -> { + // All four rolls missed: ~2/3 chance of 2 total "sympathy" damage split across + // two of the four hits, ~1/3 chance of a clean 0-0-0-0. + if (rollSympathyTriggers()) { + when (rollSympathyPattern()) { + 0 -> { + hits[0] = 1 + hits[1] = 1 + } + 1 -> { + hits[2] = 1 + hits[3] = 1 + } + 2 -> { + hits[0] = 1 + hits[2] = 1 + } + else -> { + hits[1] = 1 + hits[3] = 1 + } + } + } + } + } + return hits + } + + /** Inclusive random range, safe against a lower bound of `0` or below, honoring `::maxhit`. */ + fun resolveRange( + min: Int, + max: Int, + isMaxHit: Boolean, + rollInclusive: (Int, Int) -> Int, + rollUpTo: (Int) -> Int, + ): Int { + val safeMax = max.coerceAtLeast(0) + val safeMin = min.coerceIn(0, safeMax) + if (isMaxHit) { + return safeMax + } + return if (safeMin <= 0) { + rollUpTo(safeMax + 1) + } else { + rollInclusive(safeMin, safeMax) + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonDaggerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonDaggerSpecialAttack.kt new file mode 100644 index 000000000..bed9374f2 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonDaggerSpecialAttack.kt @@ -0,0 +1,113 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** + * Puncture: two independent hits, each using 115% accuracy and maximum damage. + * + * The attacker's selected melee style is preserved, but the target always defends + * using Slash defence. + */ +class DragonDaggerSpecialAttack @Inject constructor(private val poison: WeaponPoisonEffect) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val puncture = DragonDagger(manager, poison) + registerMelee("obj.dragon_dagger", puncture) + registerMelee("obj.dragon_dagger_p", puncture) + registerMelee("obj.dragon_dagger_p+", puncture) + registerMelee("obj.dragon_dagger_p++", puncture) + registerMelee("obj.br_dragon_dagger", puncture) + registerMelee("obj.bh_dragon_dagger_corrupted", puncture) + registerMelee("obj.bh_dragon_dagger_p_corrupted", puncture) + registerMelee("obj.bh_dragon_dagger_p+_corrupted", puncture) + registerMelee("obj.bh_dragon_dagger_p++_corrupted", puncture) + } + + private class DragonDagger( + private val manager: SpecialAttackManager, + private val poison: WeaponPoisonEffect, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + // Wiki: "There is a slight delay between the two hits when the special is used on + // NPCs" - one tick. + puncture(target, attack, secondHitDelay = 1) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + // Wiki: "When used against other players, both hits are applied simultaneously" - + // same tick, not one tick apart like this file had before. + puncture(target, attack, secondHitDelay = 0) + return true + } + + private fun ProtectedAccess.puncture( + target: PathingEntity, + attack: CombatAttack.Melee, + secondHitDelay: Int, + ) { + anim("seq.puncture") + // Height and sound confirmed against the same reference (`new Graphics(252, 0, + // 100)`, `player.sendSound(PUNCTURE_SOUND)` = synth 2537, unaliased in this cache). + soundSynth(PUNCTURE_SOUND) + spotanim( + spot = "spotanim.sp_attack_puncture_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + + val first = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.15, + maxHitMultiplier = 1.15, + blockType = MeleeAttackType.Slash, + ) + val second = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.15, + maxHitMultiplier = 1.15, + blockType = MeleeAttackType.Slash, + ) + manager.giveCombatXp(this, target, attack, first + second) + manager.queueMeleeHit(this, target, first) + manager.queueMeleeHit(this, target, second, delay = secondHitDelay) + // Puncture's two hits are still ordinary weapon hits for poison purposes - a + // dragon dagger(p)/(p+)/(p++) rolls its usual 25% chance independently on each one. + attack.weapon?.let { weapon -> + poison.rollOnMeleeHit(player, target, getInvObj(weapon), first) + poison.rollOnMeleeHit(player, target, getInvObj(weapon), second) + } + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val PUNCTURE_SOUND = 2537 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHalberdSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHalberdSpecialAttack.kt new file mode 100644 index 000000000..565a1d4c8 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHalberdSpecialAttack.kt @@ -0,0 +1,126 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.map.CoordGrid + +/** + * Sweep hits a large primary NPC twice or sweeps the three-tile line through a small target in + * multi-combat. Every hit uses Slash defence, 110% maximum damage, and an independent roll. + */ +class DragonHalberdSpecialAttack +@Inject +constructor( + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val sweep = Sweep(manager, targets, pvp, worldRepo) + registerMelee("obj.dragon_halberd", sweep) + registerMelee("obj.bh_dragon_halberd_corrupted", sweep) + } + + private class Sweep( + private val manager: SpecialAttackManager, + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = sweep(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = sweep(target, attack) + + private fun ProtectedAccess.sweep( + primary: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + anim("seq.dragon_halberd_special_attack") + soundSynth(SWEEP_SOUND) + // Ground-tile graphic, not attached to the caster: target's own tile for a + // single-tile target, otherwise the midpoint between target and caster. + val tile = + if (primary.size == 1) { + primary.coords + } else { + val centre = primary.bounds() + CoordGrid( + x = ((centre.fineCentreX + player.coords.x) / 2.0).toInt(), + z = ((centre.fineCentreZ + player.coords.z) / 2.0).toInt(), + level = player.coords.level, + ) + } + spotanimMap( + repo = worldRepo, + internal = HalberdSpecialVisuals.forTarget(player.coords, tile), + coord = tile, + height = 96, + ) + + val affected = + if (mapMultiway() && primary.size == 1) { + targets.select( + source = this, + primary = primary, + tiles = targets.halberdSweep(player, primary), + npcLimit = MAX_NPC_TARGETS, + playerLimit = MAX_PLAYER_TARGETS, + ) + } else { + listOf(primary) + } + + for (target in affected) { + val hitCount = if (target === primary && target is Npc && target.size > 1) 2 else 1 + var totalDamage = 0 + repeat(hitCount) { hit -> + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = if (hit == 0) 1.0 else SECOND_HIT_ACCURACY, + maxHitMultiplier = MAX_HIT_MULTIPLIER, + blockType = MeleeAttackType.Slash, + ) + totalDamage += damage + manager.queueMeleeHit(this, target, damage) + } + manager.giveCombatXp(this, target, attack, totalDamage) + + if (target is Player && target !== primary) { + pvp.applySecondarySpecialAttack(this, target) + } + } + manager.continueCombat(this, primary) + return true + } + + private companion object { + private const val MAX_NPC_TARGETS: Int = 10 + private const val MAX_PLAYER_TARGETS: Int = 3 + private const val MAX_HIT_MULTIPLIER: Double = 1.1 + private const val SECOND_HIT_ACCURACY: Double = 0.75 + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + private const val SWEEP_SOUND = 2533 + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHastaSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHastaSpecialAttack.kt new file mode 100644 index 000000000..e8c53f09d --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHastaSpecialAttack.kt @@ -0,0 +1,234 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.ShoveStunService +import org.rsmod.api.combat.commons.npc.combatPlayDefendAnim +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.hit.modifier.BypassProtectionPrayerPlayerHitModifier +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.route.StepFactory +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.util.PathingEntityCommon +import org.rsmod.game.map.Direction +import org.rsmod.game.map.translate +import org.rsmod.routefinder.collision.CollisionFlagMap +import org.rsmod.routefinder.flag.CollisionFlag + +/** + * Unleash consumes the whole available special bar (at least 5%) and gives 5% accuracy plus + * 2.5% maximum damage for every full 5% consumed. It always rolls against Stab defence and its + * player-versus-player hit bypasses Protect from Melee. + * + * The rev-240 cache retains the Dragon hasta's historic `brut_dragon_spear` aliases. + */ +class DragonHastaSpecialAttack +@Inject +constructor( + private val collision: CollisionFlagMap, + private val stepFactory: StepFactory, + private val stuns: ShoveStunService, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val unleash = Unleash(manager) + val shove = Shove(manager, collision, stepFactory, stuns) + + // Shove: all normal, poisoned and Bounty Hunter corrupted Dragon spear variants, + // plus the cache-native Zamorakian spear and hasta aliases. + registerMelee("obj.dragon_spear", shove) + registerMelee("obj.dragon_spear_p", shove) + registerMelee("obj.dragon_spear_p+", shove) + registerMelee("obj.dragon_spear_p++", shove) + registerMelee("obj.bh_dragon_spear_corrupted", shove) + registerMelee("obj.bh_dragon_spear_p_corrupted", shove) + registerMelee("obj.bh_dragon_spear_p+_corrupted", shove) + registerMelee("obj.bh_dragon_spear_p++_corrupted", shove) + registerMelee("obj.tbwt_dragon_spear_kp", shove) + registerMelee("obj.zamorak_spear", shove) + registerMelee("obj.zamorak_hasta", shove) + + registerMelee("obj.brut_dragon_spear", unleash) + registerMelee("obj.brut_dragon_spear_p", unleash) + registerMelee("obj.brut_dragon_spear_p+", unleash) + registerMelee("obj.brut_dragon_spear_p++", unleash) + registerMelee("obj.brut_dragon_spear_kp", unleash) + } + + private class Unleash(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = unleash(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = unleash(target, attack) + + private fun ProtectedAccess.unleash( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + val energyUsed = manager.getSpecialEnergy(this) + if (energyUsed < MINIMUM_ENERGY) { + mes("You don't have enough power left.") + return false + } + + val multipliers = UnleashMultipliers.forEnergy(energyUsed) + + // The cache assigns this weapon a specialized energy requirement, so it owns its + // complete-bar debit rather than relying on the generic special-attack debit. + manager.drainAllSpecialEnergy(this) + + // Reuses Sunspear's thrust anim - the generic placeholder didn't sync correctly live. + anim("seq.human_weapons_sunspear_spec") + spotanim( + spot = "spotanim.dragon_hasta_spec_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = multipliers.accuracy, + maxHitMultiplier = multipliers.maxHit, + blockType = MeleeAttackType.Stab, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit( + source = this, + target = target, + damage = damage, + modifier = BypassProtectionPrayerPlayerHitModifier, + ) + manager.continueCombat(this, target) + return true + } + } + + /** + * Shove is guaranteed utility: it never rolls accuracy and deliberately never queues a hit. + * It stuns for five map cycles (three seconds). Per the wiki's "Stun (status)" page, Shove's + * stun has no immunity window and can be chained indefinitely. A blocked tile prevents only + * displacement, never the valid stun. + */ + private class Shove( + private val manager: SpecialAttackManager, + private val collision: CollisionFlagMap, + private val stepFactory: StepFactory, + private val stuns: ShoveStunService, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = shove(target) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = shove(target) + + private fun ProtectedAccess.shove(target: PathingEntity): Boolean { + if (target.size > 1) { + mes("That creature is too large to knock back!") + return false + } + if (stuns.isStunned(target)) { + return false + } + + anim("seq.shove") + soundSynth(SHOVE_SOUND) + spotanim( + spot = "spotanim.sp_attack_shove_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + // Human sequence 1066 is invalid for non-human NPC skeletons. + // NPC targets use their cache-defined defend animation; PvP players + // retain the canonical shove-stun reaction sequence. + when (target) { + is Npc -> target.combatPlayDefendAnim() + is Player -> target.anim("seq.stunned_shove") + } + + target.spotanim( + spot = "spotanim.stunned_shove", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + stuns.applyStun(target, STUN_CYCLES) + manager.setNextAttackDelay(this, SHOVE_ATTACK_DELAY) + tryPush(target) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.tryPush(target: PathingEntity) { + val direction = Direction.between(player.bounds(), target.bounds()) + val destination = target.coords.translate(direction) + val validated = + when (target) { + is Player -> + stepFactory.validated( + source = target.coords, + dest = destination, + size = target.size, + extraFlag = CollisionFlag.BLOCK_PLAYERS, + ) + + is Npc -> { + val strategy = target.collisionStrategy ?: return + stepFactory.validated(target, target.coords, destination, strategy) + } + } + // [StepFactory] falls back from a blocked diagonal to a cardinal step. Shove must not: + // it moves the target one tile directly away from the attacker or leaves it in place. + if (validated != destination) { + return + } + PathingEntityCommon.teleport(target, collision, destination) + } + } + + private companion object { + const val MINIMUM_ENERGY: Int = 50 + const val STUN_CYCLES: Int = 5 + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SHOVE_SOUND = 2544 + const val SHOVE_ATTACK_DELAY: Int = 5 + } +} + +/** + * Unleash's energy-to-multiplier scaling. Wiki worked example: 100% energy (1000 in this engine's + * 0-1000 special-energy scale) gives doubled accuracy and a 50% damage boost - `forEnergy(1000)` + * matches both exactly. + */ +internal object UnleashMultipliers { + private const val ENERGY_PER_CHUNK: Int = 50 + private const val ACCURACY_PER_CHUNK: Double = 0.05 + private const val DAMAGE_PER_CHUNK: Double = 0.025 + + data class Multipliers(val accuracy: Double, val maxHit: Double) + + fun forEnergy(energyUsed: Int): Multipliers { + val fivePercentChunks = energyUsed / ENERGY_PER_CHUNK + return Multipliers( + accuracy = 1.0 + (fivePercentChunks * ACCURACY_PER_CHUNK), + maxHit = 1.0 + (fivePercentChunks * DAMAGE_PER_CHUNK), + ) + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonLongswordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonLongswordSpecialAttack.kt index 214da4c7f..14e6d83eb 100644 --- a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonLongswordSpecialAttack.kt +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonLongswordSpecialAttack.kt @@ -14,14 +14,32 @@ import org.rsmod.game.entity.Player class DragonLongswordSpecialAttack : SpecialAttackMap { override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { - registerMelee("obj.dragon_longsword", DragonLongsword(manager)) + val cleave = DragonLongsword(manager) + registerMelee("obj.dragon_longsword", cleave) + registerMelee("obj.bh_dragon_longsword_corrupted", cleave) + + // The Bounty Hunter imbue has the same 25% damage increase, but has a + // 25% accuracy increase and attacks one game cycle faster. + registerMelee( + "obj.bh_dragon_longsword_imbue", + DragonLongsword(manager, accuracyMultiplier = 1.25, nextAttackDelay = 4), + ) } - private class DragonLongsword(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + private class DragonLongsword( + private val manager: SpecialAttackManager, + private val accuracyMultiplier: Double = 1.0, + private val nextAttackDelay: Int? = null, + ) : MeleeSpecialAttack { override suspend fun ProtectedAccess.attack( target: Npc, attack: CombatAttack.Melee, ): Boolean { + if (nextAttackDelay != null) { + mes("This special attack can only be used against other players.") + manager.stopCombat(this) + return false + } cleave(target, attack) return true } @@ -36,6 +54,11 @@ class DragonLongswordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.cleave(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.cleave") + if (nextAttackDelay != null) { + manager.setNextAttackDelay(this, nextAttackDelay) + } + + soundSynth("synth.cleave") spotanim( spot = "spotanim.sp_attack_cleave_spotanim", slot = constants.spotanim_slot_combat, @@ -47,7 +70,7 @@ class DragonLongswordSpecialAttack : SpecialAttackMap { source = this, target = target, attack = attack, - accuracyMultiplier = 1.25, + accuracyMultiplier = accuracyMultiplier, maxHitMultiplier = 1.25, blockType = MeleeAttackType.Slash, ) diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonMaceSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonMaceSpecialAttack.kt new file mode 100644 index 000000000..e08c4bb4e --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonMaceSpecialAttack.kt @@ -0,0 +1,69 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** Shatter: a single Crush hit with 125% accuracy and 150% maximum damage. */ +class DragonMaceSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val shatter = DragonMace(manager) + registerMelee("obj.dragon_mace", shatter) + registerMelee("obj.bh_dragon_mace_imbue", shatter) + registerMelee("obj.bh_dragon_mace_corrupted", shatter) + } + + private class DragonMace(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + shatter(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + shatter(target, attack) + return true + } + + private fun ProtectedAccess.shatter(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.shatter") + soundSynth(SHATTER_SOUND) + spotanim( + spot = "spotanim.sp_attack_shatter_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.25, + maxHitMultiplier = 1.5, + blockType = MeleeAttackType.Crush, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SHATTER_SOUND = 2541 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonScimitarSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonScimitarSpecialAttack.kt new file mode 100644 index 000000000..c7ca83495 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonScimitarSpecialAttack.kt @@ -0,0 +1,76 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.prayer.disableProtectionPrayers +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** Sever: a single normal-maximum hit with 125% accuracy that rolls against Slash defence. */ +class DragonScimitarSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val sever = Sever(manager) + registerMelee("obj.dragon_scimitar", sever) + registerMelee("obj.dragon_scimitar_ornament", sever) + registerMelee("obj.br_dragon_scimitar", sever) + registerMelee("obj.bh_dragon_scimitar_corrupted", sever) + } + + private class Sever(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + sever(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + sever(target, attack) + return true + } + + private fun ProtectedAccess.sever(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.sp_attack_dragon_scimitar") + soundSynth(SEVER_SOUND) + spotanim( + spot = "spotanim.sp_attack_dragon_scimitar_trail_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.25, + maxHitMultiplier = 1.0, + blockType = MeleeAttackType.Slash, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value to gate the prayer-disable on - no impact callback needed. + if (damage > 0) { + (target as? Player)?.disableProtectionPrayers() + } + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SEVER_SOUND = 2540 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonSwordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonSwordSpecialAttack.kt new file mode 100644 index 000000000..c1d06029a --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonSwordSpecialAttack.kt @@ -0,0 +1,77 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.hit.modifier.BypassProtectionPrayerPlayerHitModifier +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** Wild Stab: a single hit with 125% accuracy and maximum damage against Stab defence. */ +class DragonSwordSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val wildStab = WildStab(manager) + + // The normal Dragon sword retains the historic `dragon_shortsword` cache alias. + registerMelee("obj.dragon_shortsword", wildStab) + registerMelee("obj.br_dragon_sword", wildStab) + registerMelee("obj.bh_dragon_shortsword_corrupted", wildStab) + } + + private class WildStab(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + wildStab(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + wildStab(target, attack) + return true + } + + private fun ProtectedAccess.wildStab(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.human_dragon_sword_spec") + soundSynth(WILD_STAB_SOUND) + spotanim( + spot = "spotanim.dragon_sword_spec_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.25, + maxHitMultiplier = 1.25, + blockType = MeleeAttackType.Stab, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit( + source = this, + target = target, + damage = damage, + modifier = BypassProtectionPrayerPlayerHitModifier, + ) + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val WILD_STAB_SOUND = 3552 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlSpecialAttack.kt new file mode 100644 index 000000000..55ed59b99 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlSpecialAttack.kt @@ -0,0 +1,169 @@ +package org.rsmod.content.other.special.attacks.melee + +import dev.openrune.util.Wearpos +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.inv.isAnyType +import org.rsmod.game.inv.isType + +/** + * Blood Infusion requires the full Blood Moon set. It pays a quarter of the wielder's current + * Hitpoints, then performs the macuahuitl's two hits without the normal sequential accuracy gate. + * Both the minimum and maximum total damage are raised by 25%; any landed hit guarantees the + * Bloodrager one-tick attack-speed benefit for the following attack. + */ +class DualMacuahuitlSpecialAttack @Inject constructor(private val random: GameRandom) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val bloodInfusion = BloodInfusion(manager, random) + registerMelee("obj.dual_macuahuitl", bloodInfusion) + registerMelee("obj.br_dual_macuahuitl", bloodInfusion) + } + + private class BloodInfusion( + private val manager: SpecialAttackManager, + private val random: GameRandom, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = bloodInfusion(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = bloodInfusion(target, attack) + + private fun ProtectedAccess.bloodInfusion( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + if (!player.isWearingBloodMoonSet()) { + mes("You need to be wearing the full Blood Moon armour set to use this special attack.") + manager.stopCombat(this) + return false + } + + val selfDamage = stat("stat.hitpoints") / SELF_DAMAGE_DIVISOR + if (selfDamage > 0) { + takeInstantHit(type = HitType.Typeless, damage = selfDamage) + } + + anim("seq.pmoon_macuahuitl_crush") + spotanim( + spot = "spotanim.special_dual_macuahuitl_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val normalMax = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + val hits = + DualMacuahuitlDamage.roll( + normalMax = normalMax, + rollAccuracy = { + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + }, + rollDamage = { range -> + DualMacuahuitlDamage.resolveDamage( + range = range, + isMaxHit = player.adminMaxHit, + rollRandom = { r -> + when { + r.first <= 0 -> random.of(r.last + 1) + r.first == r.last -> r.first + else -> random.of(r) + } + }, + ) + }, + ) + + val totalDamage = hits.sum() + manager.giveCombatXp(this, target, attack, totalDamage) + manager.queueMeleeHit(this, target, hits[0], delay = 1) + manager.queueMeleeHit(this, target, hits[1], delay = 2) + + // Dual macuahuitls have a four-tick attack rate. Bloodrager advances the next attack + // by one tick whenever either special hit lands. + if (hits.any { it > 0 }) { + manager.setNextAttackDelay(this, BLOODRAGER_ATTACK_DELAY) + } + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val SELF_DAMAGE_DIVISOR = 4 + const val BLOODRAGER_ATTACK_DELAY = 3 + } +} + +/** Pure damage distribution for Blood Infusion's independent two-hit sequence. */ +internal object DualMacuahuitlDamage { + fun roll( + normalMax: Int, + rollAccuracy: () -> Boolean, + rollDamage: (IntRange) -> Int, + ): IntArray { + val minimum = (normalMax * MINIMUM_PERCENT + 99) / 100 + val maximum = normalMax * MAXIMUM_PERCENT / 100 + return intArrayOf( + rollHit(splitLower(minimum), splitLower(maximum), rollAccuracy, rollDamage), + rollHit(splitUpper(minimum), splitUpper(maximum), rollAccuracy, rollDamage), + ) + } + + private fun rollHit( + minimum: Int, + maximum: Int, + rollAccuracy: () -> Boolean, + rollDamage: (IntRange) -> Int, + ): Int = if (!rollAccuracy()) 0 else rollDamage(minimum..maximum) + + /** Forces the top of the range when `::maxhit` is active, same convention as elsewhere. */ + fun resolveDamage(range: IntRange, isMaxHit: Boolean, rollRandom: (IntRange) -> Int): Int = + when { + range.last <= 0 -> 0 + isMaxHit -> range.last + else -> rollRandom(range) + } + + private fun splitLower(value: Int): Int = value / 2 + + private fun splitUpper(value: Int): Int = value - splitLower(value) + + private const val MINIMUM_PERCENT = 25 + private const val MAXIMUM_PERCENT = 125 +} + +private fun Player.isWearingBloodMoonSet(): Boolean = + worn[Wearpos.Hat.slot]?.isType("obj.blood_moon_helm") == true && + worn[Wearpos.Torso.slot]?.isType("obj.blood_moon_chestplate") == true && + worn[Wearpos.Legs.slot]?.isType("obj.blood_moon_tassets") == true && + worn[Wearpos.RightHand.slot]?.isAnyType("obj.dual_macuahuitl", "obj.br_dual_macuahuitl") == true diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ElderMaulSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ElderMaulSpecialAttack.kt new file mode 100644 index 000000000..979a97e05 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ElderMaulSpecialAttack.kt @@ -0,0 +1,95 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Pulverize is a slower, 25%-more-accurate Crush attack that removes 35% of current Defence when + * it deals damage. + */ +class ElderMaulSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val pulverize = Pulverize(manager) + registerMelee("obj.elder_maul", pulverize) + registerMelee("obj.br_elder_maul", pulverize) + registerMelee("obj.elder_maul_ornament", pulverize) + } + + private class Pulverize(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + pulverize(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + pulverize(target, attack) + return true + } + + private fun ProtectedAccess.pulverize( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + // Pulverize is one cycle slower than the maul's normal six-cycle attack. + manager.setNextAttackDelay(this, 7) + anim("seq.human_elder_maul_spec") + soundSynth(SHIELD_BASH_SOUND) + spotanim( + spot = "spotanim.spotanim_elder_maul_special", + height = 0, + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.25, + maxHitMultiplier = 1.0, + blockType = MeleeAttackType.Crush, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + // Same unverified height guess as the launch spotanim above. + target.spotanim(spot = "spotanim.spotanim_elder_maul_special_impact", height = 0) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value to gate the Defence drain on - no impact callback needed. + if (damage > 0) { + reduceDefenceBy35Percent(target) + } + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SHIELD_BASH_SOUND = 3454 + } +} + +private fun reduceDefenceBy35Percent(target: PathingEntity) { + when (target) { + is Player -> { + val drain = target.stat("stat.defence") * 35 / 100 + if (drain > 0) { + target.statSub("stat.defence", constant = drain, percent = 0) + } + } + is Npc -> target.defenceLvl = target.defenceLvl * 65 / 100 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/FangOfTheHoundSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/FangOfTheHoundSpecialAttack.kt new file mode 100644 index 000000000..74c5a7e1e --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/FangOfTheHoundSpecialAttack.kt @@ -0,0 +1,162 @@ +package org.rsmod.content.other.special.attacks.melee + +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatStance +import org.rsmod.api.combat.commons.magic.Spellbook +import org.rsmod.api.combat.commons.styles.MeleeAttackStyle +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.combat.weapon.styles.AttackStyles +import org.rsmod.api.combat.weapon.types.AttackTypes +import org.rsmod.api.config.constants +import org.rsmod.api.npc.isValidTarget as isValidNpcTarget +import org.rsmod.api.player.isValidTarget as isValidPlayerTarget +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.instant.InstantSpecialAttack +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.interact.InteractionNpcOp +import org.rsmod.game.interact.InteractionOp +import org.rsmod.game.interact.InteractionPlayerOp + +/** + * Ferocity is an instant ordinary melee hit that guarantees Flames of Cerberus when its melee + * accuracy roll succeeds. Its instant attack still imposes the Fang's normal three-cycle delay. + */ +class FangOfTheHoundSpecialAttack +@Inject +constructor( + private val attackTypes: AttackTypes, + private val attackStyles: AttackStyles, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerInstant( + "obj.fang_of_the_hound", + Ferocity( + manager = manager, + attackTypes = attackTypes, + attackStyles = attackStyles, + ), + ) + } + + private class Ferocity( + private val manager: SpecialAttackManager, + private val attackTypes: AttackTypes, + private val attackStyles: AttackStyles, + ) : InstantSpecialAttack { + override suspend fun ProtectedAccess.activate(): Boolean { + val target = currentCombatTarget() ?: return false + if (!isWithinDistance(target, MELEE_RANGE)) { + return false + } + + val attack = currentMeleeAttack() ?: return false + anim("seq.human_karambit_spec") + + val accurate = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + val damage = + if (accurate) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + } else { + 0 + } + + manager.queueMeleeHit(this, target, damage) + if (accurate) { + flamesOfCerberus(target) + } + manager.giveCombatXp(this, target, attack, damage) + manager.setNextAttackDelay(this, FANG_ATTACK_RATE) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.currentCombatTarget(): PathingEntity? = + when (val interaction = player.interaction) { + is InteractionNpcOp -> { + if (interaction.op != InteractionOp.Op2) { + null + } else { + findUid(interaction.uid)?.takeIf { it.isValidNpcTarget() } + } + } + + is InteractionPlayerOp -> { + if (interaction.op != InteractionOp.Op1 && interaction.op != InteractionOp.Op2) { + null + } else { + findUid(interaction.uid)?.takeIf { it.isValidPlayerTarget() } + } + } + + else -> null + } + + private fun ProtectedAccess.currentMeleeAttack(): CombatAttack.Melee? { + val weapon = player.righthand ?: return null + val stance = CombatStance[player.vars[COMBAT_STANCE_VARP]] ?: CombatStance.Stance1 + return CombatAttack.Melee( + weapon = weapon, + type = attackTypes.get(player) as? MeleeAttackType, + style = attackStyles.get(player) as? MeleeAttackStyle, + stance = stance, + ) + } + + private fun ProtectedAccess.flamesOfCerberus(target: PathingEntity) { + val damage = + manager.rollSpellMaxHit( + source = this, + target = target, + spell = FLAMES_OF_CERBERUS, + spellbook = Spellbook.Standard, + baseMaxHit = FLAMES_BASE_MAX_HIT, + attackRate = FANG_ATTACK_RATE, + ) + + // Same fix as the passive's identical effect in FangOfTheHoundWeapons.kt - this is a + // separate copy of the same spotanim call, not shared code, so it needed its own fix. + target.spotanim( + spot = "spotanim.vfx_flames_of_cerberus", + slot = constants.spotanim_slot_combat, + height = 0, + ) + manager.queueMagicHit( + source = this, + target = target, + damage = damage, + clientDelay = 0, + spell = FLAMES_OF_CERBERUS, + ) + } + } + + private companion object { + const val COMBAT_STANCE_VARP: String = "varp.com_mode" + const val MELEE_RANGE: Int = 1 + const val FANG_ATTACK_RATE: Int = 3 + const val FLAMES_BASE_MAX_HIT: Int = 10 + + // Cache item: obj.fang_of_the_hound_fire (id 33377). + val FLAMES_OF_CERBERUS: ItemServerType = ItemServerType(33377) + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteHammerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteHammerSpecialAttack.kt new file mode 100644 index 000000000..ee449a015 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteHammerSpecialAttack.kt @@ -0,0 +1,65 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Hammer Blow uses Crush defence with 50% extra accuracy, then adds five damage to the resulting + * roll. The addition is deliberate: a failed accuracy roll becomes the special's five-damage + * minimum hit. + */ +class GraniteHammerSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.granite_hammer", HammerBlow(manager)) + } + + private class HammerBlow(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + hammerBlow(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + hammerBlow(target, attack) + return true + } + + private fun ProtectedAccess.hammerBlow(target: PathingEntity, attack: CombatAttack.Melee) { + // Revision-240 uses the dragon warhammer special sequence for this strike. + anim("seq.dragon_warhammer_sa_player") + spotanim( + spot = "spotanim.granite_hammer_sa_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.5, + maxHitMultiplier = 1.0, + blockType = MeleeAttackType.Crush, + ) + 5 + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulSpecialAttack.kt new file mode 100644 index 000000000..07a4789f1 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulSpecialAttack.kt @@ -0,0 +1,115 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatStance +import org.rsmod.api.combat.commons.styles.MeleeAttackStyle +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.npc.isValidTarget as isValidNpcTarget +import org.rsmod.api.player.isValidTarget as isValidPlayerTarget +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.instant.InstantSpecialAttack +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.interact.InteractionNpcOp +import org.rsmod.game.interact.InteractionOp +import org.rsmod.game.interact.InteractionPlayerOp + +/** + * Quick Smash is an instant, ordinary Crush attack. It deliberately does not alter the player's + * normal attack timer: a second valid click can therefore produce the genuine second smash when + * the cache-provided energy cost permits it (the 50% variants), while the 60% variants cannot. + */ +class GraniteMaulSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val quickSmash = QuickSmash(manager) + + // Rev-240: the unupgraded and ornamental mauls retain the 60% cost, while the Battle + // Royale and + variants use 50%. The repository reads those costs from cache data. + registerInstant("obj.granite_maul", quickSmash) + registerInstant("obj.granite_maul_pretty", quickSmash) + registerInstant("obj.br_granite_maul", quickSmash) + registerInstant("obj.granite_maul_plus", quickSmash) + registerInstant("obj.granite_maul_pretty_plus", quickSmash) + } + + private class QuickSmash(private val manager: SpecialAttackManager) : InstantSpecialAttack { + override suspend fun ProtectedAccess.activate(): Boolean { + val target = currentCombatTarget() ?: return false + if (!isWithinDistance(target, MELEE_RANGE)) { + return false + } + + val weapon = player.righthand ?: return false + val attack = + CombatAttack.Melee( + weapon = weapon, + type = MeleeAttackType.Crush, + style = graniteMaulStyle(), + stance = CombatStance[player.vars[COMBAT_STANCE_VARP]] ?: CombatStance.Stance1, + ) + + anim("seq.slayer_granite_maul_special_attack") + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + attackType = MeleeAttackType.Crush, + blockType = MeleeAttackType.Crush, + ) + manager.giveCombatXp(this, target, attack, damage) + // Normal melee impact timing is retained. Consecutive instant button presses queue + // their hits for the same following cycle, which is the maul's rapid double smash. + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.currentCombatTarget(): PathingEntity? { + return when (val interaction = player.interaction) { + is InteractionNpcOp -> { + if (interaction.op != InteractionOp.Op2) { + null + } else { + findUid(interaction.uid)?.takeIf { it.isValidNpcTarget() } + } + } + + is InteractionPlayerOp -> { + if (interaction.op != InteractionOp.Op1 && interaction.op != InteractionOp.Op2) { + null + } else { + findUid(interaction.uid)?.takeIf { it.isValidPlayerTarget() } + } + } + + else -> null + } + } + + private fun ProtectedAccess.graniteMaulStyle(): MeleeAttackStyle? = + GraniteMaulStyle.resolve(CombatStance[player.vars[COMBAT_STANCE_VARP]]) + } + + private companion object { + const val COMBAT_STANCE_VARP = "varp.com_mode" + const val MELEE_RANGE = 1 + } +} + +/** Pure stance-to-style mapping, kept separate from [ProtectedAccess] so it can be unit tested. */ +internal object GraniteMaulStyle { + fun resolve(stance: CombatStance?): MeleeAttackStyle? = + when (stance) { + CombatStance.Stance1 -> MeleeAttackStyle.Accurate + CombatStance.Stance2 -> MeleeAttackStyle.Aggressive + CombatStance.Stance3, + CombatStance.Stance4, + null -> MeleeAttackStyle.Defensive + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/HalberdSpecialVisuals.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/HalberdSpecialVisuals.kt new file mode 100644 index 000000000..b71426aa3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/HalberdSpecialVisuals.kt @@ -0,0 +1,23 @@ +package org.rsmod.content.other.special.attacks.melee + +import kotlin.math.abs +import org.rsmod.map.CoordGrid + +/** + * Selects the sweep graphic that faces from the wielder toward the primary target. The halberd + * sweep spotanim is shared cache-side across several weapons, distinguished only by colour - red + * for Dragon halberd, white for Crystal halberd. + */ +internal object HalberdSpecialVisuals { + fun forTarget(source: CoordGrid, target: CoordGrid, color: String = "red"): String { + val deltaX = target.x - source.x + val deltaZ = target.z - source.z + val direction = + if (abs(deltaX) > abs(deltaZ)) { + if (deltaX >= 0) "east" else "west" + } else { + if (deltaZ >= 0) "north" else "south" + } + return "spotanim.dragon_halberd_special_${direction}_$color" + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacks.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacks.kt new file mode 100644 index 000000000..b8fd03e4c --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacks.kt @@ -0,0 +1,400 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import kotlin.math.max +import kotlin.math.min +import org.rsmod.api.combat.commons.BindEffectService +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatEffects +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statHeal +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Real OSRS doesn't clamp damage after the roll, so every effect below uses the already-known, + * pre-mitigation damage value computed a few lines earlier instead of waiting on an impact + * callback (`HitImpactHandler`, which isn't a real type anywhere in this engine - same fake-type + * bug as elsewhere in this project). This matters beyond convenience for the Saradomin godsword: + * the wiki is explicit its heal/prayer-restore is "calculated from the potential damage of a swing + * *before* some types of damage immunities are applied" and "before flat armour is applied" - the + * pre-mitigation roll is the *correct* value to use, not an engine-diff workaround. + */ +class ImpactMeleeSpecialAttacks +@Inject +constructor(private val binds: BindEffectService) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val bandos = BandosGodsword(manager, "seq.bgs_special_player", BANDOS_STANDARD_SPOTANIM) + registerMelee("obj.bgs", bandos) + registerMelee( + "obj.bgsg", + BandosGodsword(manager, "seq.bgs_special_ornate_player", BANDOS_ORNATE_SPOTANIM), + ) + + val saradomin = + SaradominGodsword(manager, "seq.sgs_special_player", SARADOMIN_STANDARD_SPOTANIM) + registerMelee("obj.sgs", saradomin) + registerMelee( + "obj.sgsg", + SaradominGodsword(manager, "seq.sgs_special_ornate_player", SARADOMIN_ORNATE_SPOTANIM), + ) + + val zamorak = + ZamorakGodsword(manager, binds, "seq.zgs_special_player", ZAMORAK_STANDARD_SPOTANIM) + registerMelee("obj.zgs", zamorak) + registerMelee( + "obj.zgsg", + ZamorakGodsword( + manager = manager, + binds = binds, + sequence = "seq.zgs_special_ornate_player", + graphic = ZAMORAK_ORNATE_SPOTANIM, + ), + ) + + val warhammer = DragonWarhammer(manager) + registerMelee("obj.dragon_warhammer", warhammer) + registerMelee("obj.br_dragon_warhammer", warhammer) + registerMelee("obj.dragon_warhammer_ornament", warhammer) + registerMelee("obj.bh_dragon_warhammer_corrupted", warhammer) + } + + private class BandosGodsword( + private val manager: SpecialAttackManager, + private val sequence: String, + private val graphic: String, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + bandosStrike(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + bandosStrike(target, attack) + return true + } + + private fun ProtectedAccess.bandosStrike( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim(sequence) + spotanim( + spot = graphic, + slot = constants.spotanim_slot_combat, + height = 0, + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 2.0, + maxHitMultiplier = 1.21, + blockType = MeleeAttackType.Slash, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(source = this, target = target, damage = damage) + if (damage > 0) { + drainBandosStats(target, damage) + } + manager.continueCombat(this, target) + } + } + + private class SaradominGodsword( + private val manager: SpecialAttackManager, + private val sequence: String, + private val graphic: String, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + healingBlade(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + healingBlade(target, attack) + return true + } + + private fun ProtectedAccess.healingBlade( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim(sequence) + spotanim( + spot = graphic, + slot = constants.spotanim_slot_combat, + height = 0, + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 2.0, + maxHitMultiplier = 1.1, + blockType = MeleeAttackType.Slash, + ) + val source = player + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(source = this, target = target, damage = damage) + // Wiki: restoration is calculated from the pre-immunity, pre-armour roll above, not the + // final post-mitigation hit - `damage` (not a post-modifier `impact.damage`) is exactly + // that value, so a target absorbing the hit down to 0 still restores normally. + if (damage > 0) { + source.statHeal("stat.hitpoints", constant = max(10, (damage + 1) / 2), percent = 0) + source.statHeal("stat.prayer", constant = max(5, (damage + 3) / 4), percent = 0) + } + manager.continueCombat(this, target) + } + } + + private class ZamorakGodsword( + private val manager: SpecialAttackManager, + private val binds: BindEffectService, + private val sequence: String, + private val graphic: String, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + iceCleave(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + iceCleave(target, attack) + return true + } + + private fun ProtectedAccess.iceCleave( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim(sequence) + spotanim( + spot = graphic, + slot = constants.spotanim_slot_combat, + height = 0, + ) + val accurate = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Slash, + multiplier = 2.0, + ) + val damage = + if (accurate) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = ZGS_MAX_HIT_MULTIPLIER, + ) + } else { + 0 + } + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(source = this, target = target, damage = damage) + // Wiki: "The sword's special attack must produce a successful hit in order to have the + // freezing effect... Protect from Melee... does not affect the freeze" - gated on the + // accuracy roll itself, not the post-mitigation damage. + if (accurate) { + // Wiki: "Freezes opponent... with a similar animation to Ice Barrage." + target.spotanim("spotanim.ice_barrage_impact") + when (target) { + is Player -> CombatEffects.freeze(target, ZGS_FREEZE_TICKS) + is Npc -> binds.bind(target, ZGS_FREEZE_TICKS) + } + } + manager.continueCombat(this, target) + } + } + + private class DragonWarhammer(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + smash(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + smash(target, attack) + return true + } + + private fun ProtectedAccess.smash( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.dragon_warhammer_sa_player") + spotanim( + spot = "spotanim.dragon_warhammer_sa_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + val accurate = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Crush, + multiplier = 1.0, + ) + val damage = + if (accurate) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = DWH_MAX_HIT_MULTIPLIER, + ) + } else { + 0 + } + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(source = this, target = target, damage = damage) + // Wiki: "unlike other weapons such as the Bandos godsword... the dragon warhammer only + // needs to roll a successful hit to reduce 30%" - gated on accuracy, not damage. + if (accurate) { + reduceDefence(target) + } + manager.continueCombat(this, target) + } + } + + internal companion object { + // Wiki: "it freezes the target in place for 19.2 seconds (32 ticks)". + const val ZGS_FREEZE_TICKS = 32 + const val ZGS_MAX_HIT_MULTIPLIER: Double = 1.1 + const val DWH_MAX_HIT_MULTIPLIER: Double = 1.5 + const val BANDOS_STANDARD_SPOTANIM = "spotanim.dh_sword_update_bandos_special_spotanim" + const val BANDOS_ORNATE_SPOTANIM = "spotanim.bandos_special_spotanim_gold" + const val SARADOMIN_STANDARD_SPOTANIM = "spotanim.dh_sword_update_saradomin_special_spotanim" + const val SARADOMIN_ORNATE_SPOTANIM = "spotanim.saradomin_special_spotanim_gold" + const val ZAMORAK_STANDARD_SPOTANIM = "spotanim.dh_sword_update_zamorak_special_spotanim" + const val ZAMORAK_ORNATE_SPOTANIM = "spotanim.zamorak_special_spotanim_gold" + + val BANDOS_PLAYER_STAT_ORDER = + listOf( + "stat.defence", + "stat.strength", + "stat.prayer", + "stat.attack", + "stat.magic", + "stat.ranged", + ) + } +} + +private fun drainBandosStats(target: PathingEntity, amount: Int) { + when (target) { + is Player -> { + val currentStats = ImpactMeleeSpecialAttacks.BANDOS_PLAYER_STAT_ORDER.map { target.stat(it) } + val drains = BandosStatDrain.distribute(amount, currentStats) + for (i in drains.indices) { + if (drains[i] > 0) { + target.statSub( + ImpactMeleeSpecialAttacks.BANDOS_PLAYER_STAT_ORDER[i], + constant = drains[i], + percent = 0, + ) + } + } + } + is Npc -> { + val currentStats = + listOf(target.defenceLvl, target.strengthLvl, target.attackLvl, target.magicLvl, target.rangedLvl) + val drains = BandosStatDrain.distribute(amount, currentStats) + target.defenceLvl -= drains[0] + target.strengthLvl -= drains[1] + target.attackLvl -= drains[2] + target.magicLvl -= drains[3] + target.rangedLvl -= drains[4] + } + } +} + +private fun reduceDefence(target: PathingEntity) { + when (target) { + is Player -> { + val drain = DragonWarhammerDefenceReduction.playerDrain(target.stat("stat.defence")) + if (drain > 0) { + target.statSub("stat.defence", constant = drain, percent = 0) + } + } + is Npc -> target.defenceLvl = DragonWarhammerDefenceReduction.npcRemaining(target.defenceLvl) + } +} + +/** + * Warstrike's stat drain: [amount] is spent down the wiki's documented order (Defence, Strength, + * Prayer, Attack, Magic, Ranged for players; NPCs have no Prayer, so it's skipped), moving to the + * next stat only once the current one hits 0. Pure - takes each stat's *current* value in that + * order and returns how much to drain from each, leaving the actual mutation to the caller. + */ +internal object BandosStatDrain { + fun distribute(amount: Int, currentStats: List): List { + var remaining = amount + val drains = MutableList(currentStats.size) { 0 } + for (i in currentStats.indices) { + val drain = min(currentStats[i], remaining) + drains[i] = drain + remaining -= drain + if (remaining == 0) { + break + } + } + return drains + } +} + +/** + * Smash's defence reduction: floor(30% of current Defence). Wiki's own worked example on a + * 75-Defence monster: 75 -> 53 -> 38 across two successful hits. That's `current - floor(current * + * 0.3)` each time, *not* `floor(current * 0.7)` - the two differ whenever `current * 30` isn't a + * clean multiple of 100 (75 -> floor(75*0.7)=52, but the wiki's real sequence is 53), since one + * floors the drained amount and the other floors the remainder directly. + */ +internal object DragonWarhammerDefenceReduction { + fun playerDrain(currentDefence: Int): Int = currentDefence * 30 / 100 + + fun npcRemaining(currentDefence: Int): Int = currentDefence - playerDrain(currentDefence) +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/InfernalTecpatlSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/InfernalTecpatlSpecialAttack.kt new file mode 100644 index 000000000..57121befc --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/InfernalTecpatlSpecialAttack.kt @@ -0,0 +1,94 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * The Infernal tecpatl's four-hit special uses the same ordered accuracy and damage-split model + * as dragon claws, with 25% increased accuracy and maximum hit. + */ +class InfernalTecpatlSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.infernal_tecpatl", FourfoldStrike(manager)) + } + + private class FourfoldStrike(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + fourfoldStrike(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + fourfoldStrike(target, attack) + return true + } + + private fun ProtectedAccess.fourfoldStrike( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.human_infernal_tecpatl_spec") + + // Wiki: "hits 4 times in quick succession with 25% increased max hit and accuracy. + // Damage is split depending on which hits pass accuracy" - the exact same cascade + // shape as Dragon claws' Slice and Dice, just with a 25% multiplier on both accuracy + // and max hit instead of no bonus. Reuses the same tested cascade math. + val maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.25, + ) + val hits = + DragonClawsDamage.rollHits( + maxHit = maxHit, + rollAccuracy = { + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.25, + ) + }, + rollRange = { range -> rollRange(range.first, range.last) }, + rollSympathyTriggers = { random.of(3) < 2 }, + rollSympathyPattern = { random.of(4) }, + ) + + var totalDamage = 0 + for (damage in hits) { + totalDamage += damage + manager.queueMeleeHit(this, target, damage) + } + manager.giveCombatXp(this, target, attack, totalDamage) + manager.continueCombat(this, target) + } + + private fun ProtectedAccess.rollRange(min: Int, max: Int): Int = + DragonClawsDamage.resolveRange( + min = min, + max = max, + isMaxHit = player.adminMaxHit, + rollInclusive = { lo, hi -> random.of(lo, hi) }, + rollUpTo = { exclusiveBound -> random.of(maxExclusive = exclusiveBound) }, + ) + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/NoxiousHalberdSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/NoxiousHalberdSpecialAttack.kt new file mode 100644 index 000000000..13a3dcc63 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/NoxiousHalberdSpecialAttack.kt @@ -0,0 +1,61 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.config.constants +import org.rsmod.api.mechanics.toxins.NoxiousHalberdVirulence +import org.rsmod.api.mechanics.toxins.impl.PlayerPoison +import org.rsmod.api.mechanics.toxins.impl.PlayerVenom +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.instant.InstantSpecialAttack + +/** + * Virulence instantly cures the wielder's poison or venom, then grants the next accurate noxious + * halberd hit a minimum equal to the condition's next damage. It grants no toxin immunity. + */ +class NoxiousHalberdSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerInstant("obj.noxious_halberd", Virulence) + registerInstant("obj.br_noxious_halberd", Virulence) + } + + private data object Virulence : InstantSpecialAttack { + override suspend fun ProtectedAccess.activate(): Boolean { + val damage = cureToxinForVirulence() + if (damage == null) { + mes("You can only use this special attack whilst you are poisoned.") + return false + } + + anim("seq.human_halberd_virulence_01") + spotanim( + spot = "spotanim.vfx_noxious_halberd_spec", + slot = constants.spotanim_slot_combat, + height = 0, + ) + NoxiousHalberdVirulence.activate(player, damage) + return true + } + + private fun ProtectedAccess.cureToxinForVirulence(): Int? = + when { + PlayerVenom.isEnvenomed(player) -> { + val strikes = player.vars["varp.venom_strikes"] + val damage = PlayerVenom.damageForStrikeIndex(strikes - 1) + PlayerVenom.clear(player) + damage + } + + PlayerPoison.isPoisoned(player) -> { + val severity = player.vars["varp.poison_severity"] + val damage = PlayerPoison.damageForSeverity(severity) + PlayerPoison.clear(player) + damage + } + + else -> null + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangSpecialAttack.kt new file mode 100644 index 000000000..4ace4b84a --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangSpecialAttack.kt @@ -0,0 +1,98 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Eviscerate rolls against Stab defence with 50% extra accuracy and uses the Fang's true maximum + * hit rather than its normal 85% damage cap. + */ +class OsmumtenFangSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val eviscerate = Eviscerate(manager) + registerMelee("obj.osmumtens_fang", eviscerate) + registerMelee("obj.osmumtens_fang_ornament", eviscerate) + registerMelee("obj.br_osmumtens_fang", eviscerate) + } + + private class Eviscerate(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + eviscerate(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + eviscerate(target, attack) + return true + } + + private fun ProtectedAccess.eviscerate( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.weapon_sword_osmumten03_special") + soundSynth(OSMUMTEN_FANG_SOUND) + spotanim( + spot = "spotanim.spotanim_weapon_sword_osmumten_special", + height = 0, + ) + + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Stab, + multiplier = 1.5, + ) + val damage = + if (successful) { + val maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + random.of(OsmumtenFangDamage.minHit(maxHit)..maxHit) + } else { + 0 + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val OSMUMTEN_FANG_SOUND = 3473 + } +} + +/** + * Pure Eviscerate math, kept separate from [ProtectedAccess] so it can be unit tested directly + * against the wiki's own worked example (true max hit 60 rolls 9-60) instead of only through a + * live combat roll. + */ +internal object OsmumtenFangDamage { + /** Eviscerate uses the true (uncapped) max hit, with a 15%-of-max floor, minimum 1. */ + fun minHit(maxHit: Int): Int = (maxHit * 15 / 100).coerceAtLeast(1) +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/RuneClawsSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/RuneClawsSpecialAttack.kt new file mode 100644 index 000000000..e1b9a39e3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/RuneClawsSpecialAttack.kt @@ -0,0 +1,71 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Impale is a single hit with 10% extra accuracy and maximum damage. It takes one cycle longer + * than the Rune claws' normal four-cycle attack. + */ +class RuneClawsSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.rune_claws", Impale(manager)) + } + + private class Impale(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + impale(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + impale(target, attack) + return true + } + + private fun ProtectedAccess.impale(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.impale") + soundSynth(IMPALE_SOUND) + spotanim( + spot = "spotanim.sp_attack_impale_spotanim", + slot = constants.spotanim_slot_combat, + height = 100, + ) + + // Rune claws normally attack every four cycles; Impale is a five-cycle attack. + manager.setNextAttackDelay(this, 5) + + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.1, + maxHitMultiplier = 1.1, + blockType = attack.type, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val IMPALE_SOUND = 2534 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominBlessedSwordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominBlessedSwordSpecialAttack.kt new file mode 100644 index 000000000..88e84a0c3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominBlessedSwordSpecialAttack.kt @@ -0,0 +1,98 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Saradomin's Blessed Lightning is Magic-based melee damage: its accuracy is the wielder's Slash + * attack bonus against Magic defence, while its maximum hit is the normal melee maximum ? 1.25. + */ +class SaradominBlessedSwordSpecialAttack @Inject constructor(private val worldRepo: WorldRepository) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val lightning = BlessedLightning(manager, worldRepo) + registerMelee("obj.blessed_saradomin_sword", lightning) + registerMelee("obj.blessed_saradomin_sword_degraded", lightning) + } + + private class BlessedLightning( + private val manager: SpecialAttackManager, + private val worldRepo: WorldRepository, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + lightning(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + lightning(target, attack) + return true + } + + private fun ProtectedAccess.lightning(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.blessed_saradomin_sword_special_player") + // Shares the base sword's caster glow and target lightning, plus a third effect the + // base sword lacks: a ground-location graphic at the target's own tile. + spotanim( + spot = "spotanim.dh_sword_update_saradomin_god_special_spotanim", + height = 0, + slot = constants.spotanim_slot_combat, + ) + target.spotanim( + spot = "spotanim.godwars_saradomin_magic_attack_spotanim", + height = 0, + delay = 30, + slot = constants.spotanim_slot_combat, + ) + spotanimMap( + repo = worldRepo, + internal = "spotanim.godwars_saradomin_light_attk_spot", + coord = target.coords, + height = 0, + delay = 30, + ) + + val successful = + manager.rollMagicalMeleeAccuracy( + source = this, + target = target, + attackType = MeleeAttackType.Slash, + attackStyle = attack.style, + multiplier = 1.0, + ) + val damage = + if (successful) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.25, + ) + } else { + 0 + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMagicHit(this, target, damage, clientDelay = 0) + manager.continueCombat(this, target) + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominSwordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominSwordSpecialAttack.kt new file mode 100644 index 000000000..c5fb20d44 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominSwordSpecialAttack.kt @@ -0,0 +1,114 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.statAdvance +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Saradomin's Lightning is a Slash-defence melee hit with 10% extra max damage plus an + * accuracy-dependent 1?16 Magic hit. The extra Magic hit deliberately splashes through Protect + * from Magic rather than receiving the usual partial PvP prayer reduction. + */ +class SaradominSwordSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.saradomin_sword", SaradominLightning(manager)) + } + + private class SaradominLightning(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + lightning(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + lightning(target, attack) + return true + } + + private fun ProtectedAccess.lightning(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.saradomin_sword_special_player") + soundSynth(SARADOMINS_LIGHTNING_SWORD_SOUND) + soundSynth(SARADOMINS_LIGHTNING_SOUND) + // Caster gets a glow (spotanim.dh_sword_update_saradomin_god_special_spotanim); the + // target's lightning fires 30 cycles later, timed to the swing's impact. + spotanim( + spot = "spotanim.dh_sword_update_saradomin_god_special_spotanim", + height = 0, + slot = constants.spotanim_slot_combat, + ) + target.spotanim( + spot = "spotanim.godwars_saradomin_magic_attack_spotanim", + height = 0, + delay = 30, + slot = constants.spotanim_slot_combat, + ) + + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Slash, + multiplier = 1.0, + ) + val meleeDamage = + if (successful) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.1, + ) + } else { + 0 + } + // The extra spell is conditional on accuracy, not the first hitsplat being non-zero. + val magicDamage = + if (successful && !target.protectsFromMagic()) { + random.of(MAGIC_DAMAGE_RANGE) + } else { + 0 + } + val source = player + + manager.giveCombatXp(this, target, attack, meleeDamage) + manager.queueMeleeHit(this, target, meleeDamage) + manager.queueMagicHit(source = this, target = target, damage = magicDamage, clientDelay = 0) + // Real OSRS doesn't clamp damage after the roll, so the already-known magicDamage + // here is the authentic value for the bonus xp - no impact callback needed. + if (magicDamage > 0) { + source.statAdvance("stat.magic", magicDamage * MAGIC_XP_PER_DAMAGE) + } + manager.continueCombat(this, target) + } + + private companion object { + val MAGIC_DAMAGE_RANGE = 1..16 + const val MAGIC_XP_PER_DAMAGE = 2.0 + + /** Unaliased in this cache's gamevals - no `synth.` names exist for these. */ + const val SARADOMINS_LIGHTNING_SWORD_SOUND = 3869 + const val SARADOMINS_LIGHTNING_SOUND = 3887 + } + } +} + +private fun PathingEntity.protectsFromMagic(): Boolean = + this is Player && vars["varbit.prayer_protectfrommagic"] == 1 diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeSpecialAttack.kt new file mode 100644 index 000000000..c26687cb6 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeSpecialAttack.kt @@ -0,0 +1,151 @@ +package org.rsmod.content.other.special.attacks.melee + +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.mechanics.toxins.SoulreaperStackDecay +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.player.vars.VarPlayerIntMapSetter +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Behead consumes the axe's Soul Stacks rather than ordinary special-attack energy. The cache + * marks its energy requirement as specialised, so this map owns the stack validation and debit. + */ +class SoulreaperAxeSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val behead = Behead(manager) + registerMelee("obj.soulreaper", behead) + registerMelee("obj.soulreaper_axe_orn", behead) + } + + private class Behead(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = behead(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = behead(target, attack) + + private fun ProtectedAccess.behead( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + val stacks = vars[SOUL_STACKS].coerceIn(0, MAX_SOUL_STACKS) + if (stacks == 0) { + mes("You need at least one Soul Stack to use this special attack.") + manager.stopCombat(this) + return false + } + + // Behead has one human animation. The normal and ornamented axes differ only in the + // revision-240 combat graphic. + val ornamented = player.righthand?.id == ORNAMENTED_SOULREAPER_ID + anim(BEHEAD_SEQUENCE) + spotanim( + spot = if (ornamented) ORNAMENTED_BEHEAD_SPOTANIM else BEHEAD_SPOTANIM, + slot = constants.spotanim_slot_combat, + height = 0, + ) + + // The normal strength bonus is removed first. Behead applies its own damage and + // accuracy bonus from the consumed count, rather than doubling the active stacks. + SoulreaperStackDecay.clear(player) + VarPlayerIntMapSetter.set(player, SOUL_STACKS, 0) + statHeal("stat.hitpoints", constant = stacks * HEAL_PER_STACK, percent = 0) + + // Wiki: 12% accuracy and 6% max/min damage per stack - not the same percentage for + // both (5 stacks = 60% accuracy, 30% max hit, and a *minimum* damage of 30% of that + // boosted max hit). The minimum floor isn't expressible via the standard + // rollMeleeDamage (always rolls 1..maxHit), so this rolls it directly, same pattern + // as Dragon claws' rollRange - including respecting adminMaxHit for testability. + val accuracyMultiplier = SoulreaperAxeDamage.accuracyMultiplier(stacks) + val damageMultiplier = SoulreaperAxeDamage.damageMultiplier(stacks) + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = accuracyMultiplier, + ) + val maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = damageMultiplier, + ) + val damage = + SoulreaperAxeDamage.resolveDamage( + successful = successful, + maxHit = maxHit, + stacks = stacks, + isMaxHit = player.adminMaxHit, + rollInclusive = { lo, hi -> random.of(lo, hi) }, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val SOUL_STACKS = "varp.soulreaper_stacks" + const val MAX_SOUL_STACKS = 5 + val ORNAMENTED_SOULREAPER_ID = "obj.soulreaper_axe_orn".asRSCM(RSCMType.OBJ) + const val BEHEAD_SEQUENCE = "seq.ancient_axe_special" + const val BEHEAD_SPOTANIM = "spotanim.ancient_axe_special_spotanim" + const val ORNAMENTED_BEHEAD_SPOTANIM = "spotanim.ancient_axe_special_spotanim_orn" + const val HEAL_PER_STACK = 8 + } +} + +/** + * Pure damage math for Behead, kept separate from [ProtectedAccess] so the per-stack scaling and + * the minimum-damage floor can be unit tested directly against the wiki's numbers. + */ +internal object SoulreaperAxeDamage { + fun accuracyMultiplier(stacks: Int): Double = 1.0 + (stacks * ACCURACY_BONUS_PER_STACK) + + fun damageMultiplier(stacks: Int): Double = 1.0 + (stacks * DAMAGE_BONUS_PER_STACK) + + /** 6% of the (already-boosted) max hit per stack - 30% of it at the full 5 stacks. */ + fun minimumDamage(maxHit: Int, stacks: Int): Int = (maxHit * stacks * DAMAGE_BONUS_PER_STACK).toInt() + + fun resolveDamage( + successful: Boolean, + maxHit: Int, + stacks: Int, + isMaxHit: Boolean, + rollInclusive: (Int, Int) -> Int, + ): Int { + if (!successful) { + return 0 + } + return if (isMaxHit) { + maxHit + } else { + rollInclusive(minimumDamage(maxHit, stacks), maxHit) + } + } + + const val ACCURACY_BONUS_PER_STACK = 0.12 + const val DAMAGE_BONUS_PER_STACK = 0.06 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperStackDecayScript.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperStackDecayScript.kt new file mode 100644 index 000000000..21d2c19b2 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperStackDecayScript.kt @@ -0,0 +1,57 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import org.rsmod.api.game.process.GameLifecycle +import org.rsmod.api.mechanics.toxins.SoulreaperStackDecay +import org.rsmod.api.player.vars.VarPlayerIntMapSetter +import org.rsmod.api.script.onEvent +import org.rsmod.game.MapClock +import org.rsmod.game.entity.Player +import org.rsmod.game.entity.PlayerList +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** Decays one Soul Stack after 50 cycles (30s) without an axe attack. */ +class SoulreaperStackDecayScript +@Inject +constructor( + private val worldClock: MapClock, + private val players: PlayerList, +) : PluginScript() { + override fun ScriptContext.startup() { + onEvent { decayDueStacks() } + } + + private fun decayDueStacks() { + for (player in players) { + decayOneSoulStack(player) + } + } + + private fun decayOneSoulStack(player: Player) { + if (!SoulreaperStackDecay.isDue(player, worldClock.cycle)) { + return + } + + val stacks = player.vars[SOUL_STACKS].coerceIn(0, MAX_SOUL_STACKS) + if (stacks == 0) { + SoulreaperStackDecay.clear(player) + return + } + + val remaining = stacks - 1 + VarPlayerIntMapSetter.set(player, SOUL_STACKS, remaining) + // No heal here - a stack lost to inactivity is a pure loss. The 8-per-stack heal belongs + // only to Behead actually consuming stacks (SoulreaperAxeSpecialAttack), not to decay. + if (remaining > 0) { + SoulreaperStackDecay.reset(player, worldClock.cycle) + } else { + SoulreaperStackDecay.clear(player) + } + } + + private companion object { + const val SOUL_STACKS = "varp.soulreaper_stacks" + const val MAX_SOUL_STACKS = 5 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/StatiusWarhammerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/StatiusWarhammerSpecialAttack.kt new file mode 100644 index 000000000..21518ff5d --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/StatiusWarhammerSpecialAttack.kt @@ -0,0 +1,117 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Smash rolls ordinary Crush accuracy and deals 25-125% of the wielder's normal maximum hit. + * A non-zero impact lowers current Defence by 30%; the Bounty Hunter variant uses its 75% drain. + */ +class StatiusWarhammerSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val standard = Smash(manager, defenceDrainPercent = 30) + registerMelee("obj.statius_warhammer", standard) + registerMelee("obj.br_statius_warhammer", standard) + + registerMelee( + "obj.statius_warhammer_bh", + Smash(manager, defenceDrainPercent = 75), + ) + } + + private inner class Smash( + private val manager: SpecialAttackManager, + private val defenceDrainPercent: Int, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + smash(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + smash(target, attack) + return true + } + + private fun ProtectedAccess.smash(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.human_blunt_pound") + soundSynth(SMASH_SOUND) + spotanim( + spot = "spotanim.statius_hammer_sa_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Crush, + multiplier = 1.0, + ) + val damage = + if (successful) { + val normalMax = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + val minHit = normalMax / 4 + val maxHit = normalMax + (normalMax / 4) + random.of(minHit..maxHit) + } else { + 0 + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value to gate the Defence drain on - no impact callback needed. + if (damage > 0) { + reduceDefence(target, defenceDrainPercent) + } + manager.continueCombat(this, target) + } + } + + private fun reduceDefence(target: PathingEntity, percent: Int) { + when (target) { + is Player -> { + val drain = target.stat("stat.defence") * percent / 100 + if (drain > 0) { + target.statSub("stat.defence", constant = drain, percent = 0) + } + } + is Npc -> { + val drain = target.defenceLvl * percent / 100 + target.defenceLvl = (target.defenceLvl - drain).coerceAtLeast(0) + } + } + } + + private companion object { + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SMASH_SOUND = 2520 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SunspearSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SunspearSpecialAttack.kt new file mode 100644 index 000000000..fe55a590a --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SunspearSpecialAttack.kt @@ -0,0 +1,91 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.hitpoints +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Seeking Lunge deals exactly 70% of the wielder's normal maximum melee hit on a successful hit. + * + * When that fixed damage would defeat the target, its attack roll is fixed at 70% instead of being + * randomized. The target still rolls its normal defence. + */ +class SunspearSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee("obj.sunspear", SeekingLunge(manager)) + } + + private class SeekingLunge(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + seekingLunge(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + seekingLunge(target, attack) + return true + } + + private fun ProtectedAccess.seekingLunge( + target: PathingEntity, + attack: CombatAttack.Melee, + ) { + anim("seq.human_weapons_sunspear_spec") + spotanim("spotanim.vfx_sunspear_special") + val maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + val fixedDamage = (maxHit * DAMAGE_MULTIPLIER).toInt() + // Wiki: "Seeking Lunge will roll with exactly 70% of its maximum accuracy" when the + // fixed damage would defeat the target - i.e. the same accuracy roll as normal, just + // with a 70% multiplier applied instead of 100%. This engine's attack roll is already + // a deterministic value (no RNG) scaled by `multiplier` before the single random + // hit/miss sample, so this is exactly the standard roll with a different multiplier - + // no separate "fixed roll" mechanism needed. + val accuracyMultiplier = + if (target.hitpoints() <= fixedDamage) FIXED_ACCURACY_MULTIPLIER else 1.0 + val accurate = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = accuracyMultiplier, + ) + val damage = if (accurate) fixedDamage else 0 + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + } + + private companion object { + const val DAMAGE_MULTIPLIER: Double = 0.70 + const val FIXED_ACCURACY_MULTIPLIER: Double = 0.70 + } + } +} + +private fun PathingEntity.hitpoints(): Int = + when (this) { + is Npc -> hitpoints + is Player -> hitpoints + } diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ThunderKhopeshSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ThunderKhopeshSpecialAttack.kt new file mode 100644 index 000000000..d3f407364 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ThunderKhopeshSpecialAttack.kt @@ -0,0 +1,377 @@ +package org.rsmod.content.other.special.attacks.melee + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import java.util.IdentityHashMap +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.death.NpcAttackValidateHook +import org.rsmod.api.death.NpcAttackValidateResult +import org.rsmod.api.npc.hit.modifier.NpcHitModifier +import org.rsmod.api.npc.hit.queueHit +import org.rsmod.api.npc.isValidTarget +import org.rsmod.api.player.hit.queueImpactHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.random.GameRandom +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.repo.player.PlayerRepository +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.interact.InteractionOp +import org.rsmod.game.queue.WorldQueueList +import org.rsmod.map.CoordGrid +import org.rsmod.map.zone.ZoneKey + +/** + * Lingering Lightning and the Deadman Lightning Strike use the cache-defined energy costs. + * + * The two League hits and both PvM variants are modeled independently from Deadman's documented + * player-only branch. No substitute effect is assigned to the League weapon's unverified PvP + * lightning behavior; its verified two-hit portion still applies against a player. + */ +class ThunderKhopeshSpecialAttack +@Inject +constructor(private val lightning: ThunderKhopeshLightning) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee( + "obj.thunder_khopesh", + LingeringLightning( + manager = manager, + lightning = lightning, + variant = KhopeshVariant.League, + ), + ) + registerMelee( + "obj.deadman_thunder_khopesh", + LingeringLightning( + manager = manager, + lightning = lightning, + variant = KhopeshVariant.Deadman, + ), + ) + } + + private class LingeringLightning( + private val manager: SpecialAttackManager, + private val lightning: ThunderKhopeshLightning, + private val variant: KhopeshVariant, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = lingeringLightning(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = + when (variant) { + KhopeshVariant.League -> leaguePlayerStrike(target, attack) + KhopeshVariant.Deadman -> deadmanPlayerStrike(target, attack) + } + + private fun ProtectedAccess.lingeringLightning( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + playSpecialVisuals(target) + val normalMaximum = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + val hits = doubleStrike(target, attack) + if (hits.any { it.successful }) { + lightning.schedulePvm( + access = this, + manager = manager, + attack = attack, + centre = target.coords, + maximumHit = normalMaximum * variant.pvmLightningPercent / 100, + ) + } + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.leaguePlayerStrike( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + playSpecialVisuals(target) + doubleStrike(target, attack) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.deadmanPlayerStrike( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + playSpecialVisuals(target) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = DEADMAN_PLAYER_MAX_HIT_MULTIPLIER, + ) + val targetTile = target.coords + manager.giveCombatXp(this, target, attack, damage) + val initialHit = manager.queueMeleeHit(this, target, damage) + if (initialHit.damage > 0) { + lightning.scheduleDeadmanPvp( + access = this, + primary = target, + targetTile = targetTile, + initialDamage = initialHit.damage, + ) + } + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.playSpecialVisuals(target: PathingEntity) { + anim("seq.human_special_khopesh") + spotanim("spotanim.fx_khopesh_special") + target.spotanim("spotanim.fx_khopesh_lightning_special") + } + + private fun ProtectedAccess.doubleStrike( + target: PathingEntity, + attack: CombatAttack.Melee, + ): List { + val first = rollStrike(target, attack) + val second = rollStrike(target, attack) + manager.giveCombatXp(this, target, attack, first.damage + second.damage) + manager.queueMeleeHit(this, target, first.damage, delay = FIRST_HIT_DELAY) + manager.queueMeleeHit(this, target, second.damage, delay = SECOND_HIT_DELAY) + return listOf(first, second) + } + + private fun ProtectedAccess.rollStrike( + target: PathingEntity, + attack: CombatAttack.Melee, + ): KhopeshRoll { + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + if (!successful) { + return KhopeshRoll(successful = false, damage = 0) + } + + val maximum = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = DOUBLE_STRIKE_MAX_HIT_MULTIPLIER, + ) + val damage = + if (maximum > 0) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = DOUBLE_STRIKE_MAX_HIT_MULTIPLIER, + ) + } else { + 0 + } + return KhopeshRoll(successful = true, damage = damage) + } + } + + private enum class KhopeshVariant(val pvmLightningPercent: Int) { + League(pvmLightningPercent = 225), + Deadman(pvmLightningPercent = 100), + } + + private companion object { + const val DOUBLE_STRIKE_MAX_HIT_MULTIPLIER: Double = 1.5 + const val DEADMAN_PLAYER_MAX_HIT_MULTIPLIER: Double = 1.3 + const val FIRST_HIT_DELAY: Int = 1 + const val SECOND_HIT_DELAY: Int = 2 + } +} + +private data class KhopeshRoll(val successful: Boolean, val damage: Int) + +/** + * Resolves fixed-tile lightning after the initiating special has already completed its melee + * setup. Each delayed bolt uses the dedicated revision-240 lightning graphic. + */ +@Singleton +class ThunderKhopeshLightning +@Inject +constructor( + private val worldQueues: WorldQueueList, + private val random: GameRandom, + private val npcs: NpcRepository, + private val players: PlayerRepository, + private val npcHitModifier: NpcHitModifier, + private val npcAttackValidateHooks: Set, + private val pvp: PvPAreaAttackManager, +) { + /** + * The published PvM description says the bolt is delayed but does not specify a separate + * client-cycle count. The next world cycle is the narrowest supported delayed resolve and + * preserves the target's captured tile for the 3x3 impact. + */ + fun schedulePvm( + access: ProtectedAccess, + manager: SpecialAttackManager, + attack: CombatAttack.Melee, + centre: CoordGrid, + maximumHit: Int, + ) { + if (maximumHit <= 0) { + return + } + val source = access.player + val sourceUid = source.uid.packed + worldQueues.add(PVM_LIGHTNING_DELAY) { + if (!source.isCurrent(sourceUid)) { + return@add + } + + val zone = ZoneKey.from(centre) + for (target in npcs.findAll(zone, zoneRadius = SEARCH_ZONE_RADIUS)) { + if (!target.occupiesLightningTile(centre) || !canAttack(source, target)) { + continue + } + target.spotanim("spotanim.fx_khopesh_lightning_special_extra") + val damage = random.of(0..maximumHit) + manager.giveCombatXp(access, target, attack, damage) + target.queueHit( + source = source, + delay = TARGET_HIT_DELAY, + type = HitType.Typeless, + damage = damage, + modifier = npcHitModifier, + ) + } + } + } + + /** + * Captures the tile and multi-combat state at use time. Each queue is armed from that same + * moment, which yields the documented 7, 9, 11, and 13 tick bolt schedule. + */ + fun scheduleDeadmanPvp( + access: ProtectedAccess, + primary: Player, + targetTile: CoordGrid, + initialDamage: Int, + ) { + if (initialDamage <= 0) { + return + } + + val source = access.player + val sourceUid = source.uid.packed + val primaryUid = primary.uid.packed + val multiway = access.mapMultiway() + val registeredSecondaryTargets = IdentityHashMap() + repeat(DEADMAN_BOLT_COUNT) { index -> + val delay = DEADMAN_FIRST_BOLT_DELAY + (index * DEADMAN_BOLT_INTERVAL) + worldQueues.add(delay) { + if (!source.isCurrent(sourceUid)) { + return@add + } + + val affected = + targetsAt( + source = source, + primary = primary, + primaryUid = primaryUid, + targetTile = targetTile, + multiway = multiway, + ) + for (target in affected) { + target.spotanim("spotanim.fx_khopesh_lightning_special_extra") + val damage = + initialDamage * random.of( + DEADMAN_MINIMUM_DAMAGE_PERCENT..DEADMAN_MAXIMUM_DAMAGE_PERCENT, + ) / 100 + target.queueImpactHit( + source = source, + delay = TARGET_HIT_DELAY, + type = HitType.Magic, + damage = damage, + ) + if (target !== primary && registeredSecondaryTargets.put(target, Unit) == null) { + pvp.applySecondarySpecialAttack(access, target) + } + } + } + } + } + + private fun targetsAt( + source: Player, + primary: Player, + primaryUid: Int, + targetTile: CoordGrid, + multiway: Boolean, + ): List { + val candidates = + players + .findAll(targetTile) + .filter { it !== source && pvp.canAttack(source, it) } + if (!multiway) { + return candidates + .filter { it === primary && it.uid.packed == primaryUid } + .take(SINGLE_COMBAT_TARGET_LIMIT) + .toList() + } + return candidates.take(MULTI_COMBAT_TARGET_LIMIT).toList() + } + + private fun canAttack(source: Player, target: Npc): Boolean { + if (!target.isValidTarget() || !target.visType.hasOp(InteractionOp.Op2.slot)) { + return false + } + return npcAttackValidateHooks.all { hook -> + hook.validate(source, target) !is NpcAttackValidateResult.Deny + } + } + + private fun Npc.occupiesLightningTile(centre: CoordGrid): Boolean = + bounds().asSequence().any { it.chebyshevDistance(centre) <= LIGHTNING_RADIUS } + + private fun Player.isCurrent(uid: Int): Boolean = isSlotAssigned && this.uid.packed == uid + + private companion object { + const val PVM_LIGHTNING_DELAY: Int = 1 + const val TARGET_HIT_DELAY: Int = 1 + const val LIGHTNING_RADIUS: Int = 1 + const val SEARCH_ZONE_RADIUS: Int = 1 + + const val DEADMAN_BOLT_COUNT: Int = 4 + const val DEADMAN_FIRST_BOLT_DELAY: Int = 7 + const val DEADMAN_BOLT_INTERVAL: Int = 2 + const val DEADMAN_MINIMUM_DAMAGE_PERCENT: Int = 30 + const val DEADMAN_MAXIMUM_DAMAGE_PERCENT: Int = 45 + const val SINGLE_COMBAT_TARGET_LIMIT: Int = 1 + const val MULTI_COMBAT_TARGET_LIMIT: Int = 9 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VampyreFlailSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VampyreFlailSpecialAttack.kt new file mode 100644 index 000000000..e76caf4f5 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VampyreFlailSpecialAttack.kt @@ -0,0 +1,213 @@ +package org.rsmod.content.other.special.attacks.melee + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MagicSpecialAttack +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player +import org.rsmod.game.inv.InvObj + +/** + * Retainer traps a vampyre juvenile or juvinate below half health, preventing retaliation for + * thirty seconds. It deals no damage. + */ +class VampyreFlailSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val retainer = Retainer(manager) + val rodRetainer = RodOfIvandisRetainer(manager) + registerMelee("obj.ivandis_flail", retainer) + registerMelee("obj.blisterwood_flail", retainer) + registerMelee("obj.hallowed_flail", retainer) + (1..10).forEach { charges -> + registerMagic("obj.burgh_rod_command_final_$charges", rodRetainer) + } + } + + private class Retainer(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + val heldType = target.retainerHeldType() + if (heldType == null) { + mes(NON_VAMPYRE_MESSAGE) + manager.continueCombat(this, target) + return false + } + if (target.hitpoints * 2 > target.baseHitpointsLvl) { + mes(HEALTH_MESSAGE) + manager.continueCombat(this, target) + return false + } + + playRetainerVisuals(manager, target, heldType, RETAINER_DURATION_CYCLES) + target.clearQueue(RETALIATION_QUEUE) + target.clearInteraction() + manager.continueCombat(this, target) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + mes(NON_VAMPYRE_MESSAGE) + manager.continueCombat(this, target) + return false + } + + private companion object { + const val RETAINER_DURATION_CYCLES: Int = 50 + const val RETALIATION_QUEUE: String = "queue.com_retaliate_player" + const val NON_VAMPYRE_MESSAGE: String = + "This spell can only be used on vampyre juveniles or vampyre juvinates." + const val HEALTH_MESSAGE: String = + "This spell only works against vampyres with less than half of their health remaining." + } + } + + /** + * Staff-form Retainer consumes one Rod of Ivandis charge only after a valid vampyre target + * is trapped. The cache stores the ten charge states as separate equipped items. + */ + private class RodOfIvandisRetainer(private val manager: SpecialAttackManager) : MagicSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Staff, + ): Boolean = retainer(target) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Staff, + ): Boolean { + mes(NON_VAMPYRE_MESSAGE) + manager.continueCombat(this, target) + return false + } + + private suspend fun ProtectedAccess.retainer(target: Npc): Boolean { + val heldType = target.retainerHeldType() + if (heldType == null) { + mes(NON_VAMPYRE_MESSAGE) + manager.continueCombat(this, target) + return false + } + if (target.hitpoints * 2 > target.baseHitpointsLvl) { + mes(HEALTH_MESSAGE) + manager.continueCombat(this, target) + return false + } + + playRetainerVisuals(manager, target, heldType, RETAINER_DURATION_CYCLES) + target.clearQueue(RETALIATION_QUEUE) + target.clearInteraction() + consumeRodCharge() + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.consumeRodCharge() { + val rod = player.righthand ?: return + val nextId = + when (rod.id) { + in FIRST_CHARGED_ROD_ID until LAST_CHARGED_ROD_ID -> rod.id + 1 + LAST_CHARGED_ROD_ID -> ROD_DUST_ID + else -> return + } + player.righthand = InvObj(ItemServerType(nextId), rod.count, rod.vars) + } + + private companion object { + const val FIRST_CHARGED_ROD_ID: Int = 7639 + const val LAST_CHARGED_ROD_ID: Int = 7648 + const val ROD_DUST_ID: Int = 7636 + const val RETAINER_DURATION_CYCLES: Int = 50 + const val RETALIATION_QUEUE: String = "queue.com_retaliate_player" + const val NON_VAMPYRE_MESSAGE: String = + "This spell can only be used on vampyre juveniles or vampyre juvinates." + const val HEALTH_MESSAGE: String = + "This spell only works against vampyres with at most half of their health remaining." + } + } +} + +private suspend fun ProtectedAccess.playRetainerVisuals( + manager: SpecialAttackManager, + target: Npc, + heldType: dev.openrune.types.NpcServerType, + duration: Int, +) { + val hallowed = player.righthand?.id == "obj.hallowed_flail".asRSCM(RSCMType.OBJ) + anim(if (hallowed) HALLOWED_RETAINER_CAST else RETAINER_CAST) + spotanim( + spot = if (hallowed) HALLOWED_RETAINER_CAST_SPOT else RETAINER_CAST_SPOT, + slot = constants.spotanim_slot_combat, + height = 0, + ) + val projectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = RETAINER_TRAVEL, + projanim = RETAINER_PROJANIM, + ) + delay(projectile.serverCycles) + target.spotanim( + spot = RETAINER_IMPACT, + slot = constants.spotanim_slot_combat, + height = 0, + ) + npcChangeType(target, heldType, duration) + target.anim(RETAINER_CAPTURE_START) +} + +private fun Npc.retainerHeldType(): dev.openrune.types.NpcServerType? { + val heldId = RETAINER_HELD_TYPE_BY_BASE[type.id] ?: return null + return ServerCacheManager.getNpc(heldId) +} + +private val RETAINER_HELD_TYPE_BY_BASE: Map = + buildMap { + fun pair(base: String, held: String) { + put(base.asRSCM(RSCMType.NPC), held.asRSCM(RSCMType.NPC)) + } + + pair("npc.sang_myq3_male_juvenile", "npc.sang_myq3_male_juvenile_held") + pair("npc.sang_myq3_female_juvenile", "npc.sang_myq3_female_juvenile_held") + pair("npc.sang_myq3_male_juvinate", "npc.sang_myq3_male_juvinate_held") + pair("npc.sang_myq3_female_juvinate", "npc.sang_myq3_female_juvinate_held") + for (variant in 1..4) { + pair("npc.burgh_vampire_juve_$variant", "npc.burgh_vampire_juve_held") + } + pair("npc.burgh_vampire_juve_1_attackable", "npc.burgh_vampire_juve_held") + pair("npc.burgh_vampire_juve_2_attackable", "npc.burgh_vampire_juve_held") + for (variant in 1..3) { + pair("npc.burgh_vampire_juvenile_$variant", "npc.burgh_vampire_juvenile_held") + pair("npc.trek_vampire_juve_angry_$variant", "npc.trek_vampire_juve_held_$variant") + } + for (variant in 1..2) { + pair("npc.myq5_trek_juvinate_$variant", "npc.myq5_trek_juvinate_${variant}_held") + pair("npc.darkm_juvinate_0$variant", "npc.darkm_juvinate_0${variant}_held") + pair("npc.darkm_juvenile_0$variant", "npc.darkm_juvenile_0${variant}_held") + } + } + +private const val RETAINER_CAST = "seq.burgh_human_holding_cast" +private const val HALLOWED_RETAINER_CAST = "seq.burgh_human_holding_cast_hallowed_flail" +private const val RETAINER_CAPTURE_START = "seq.burgh_human_holding_capture_start" +private const val RETAINER_CAST_SPOT = "spotanim.burgh_holding_cast_player_spot" +private const val HALLOWED_RETAINER_CAST_SPOT = "spotanim.burgh_holding_cast_player_spot_red" +private const val RETAINER_TRAVEL = "spotanim.burgh_hold_travel" +private const val RETAINER_IMPACT = "spotanim.burgh_hold_hit" +private const val RETAINER_PROJANIM = "projanim.magic_spell_low" diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordSpecialAttack.kt new file mode 100644 index 000000000..76fc531e3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordSpecialAttack.kt @@ -0,0 +1,112 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.types.MeleeAttackType +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Feint makes one accuracy roll using the selected attack style against one quarter of the + * target's Stab defence. A successful hit deals an inclusive 20-120% of the normal melee maximum + * hit. + * + * The Vesta's longsword variants use the cache-mapped 25% special-attack energy requirement. + */ +class VestaLongswordSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val feint = Feint(manager) + registerMelee("obj.vestas_longsword", feint) + registerMelee("obj.br_vestas_longsword", feint) + registerMelee("obj.bh_vestas_longsword", feint) + registerMelee("obj.vestas_longsword_bh", feint) + } + + private class Feint(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = feint(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = feint(target, attack) + + private fun ProtectedAccess.feint( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + player.anim(VESTA_LONGSWORD_FEINT_ANIMATION) + + val successful = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = MeleeAttackType.Stab, + multiplier = 1.0, + defenceMultiplier = STAB_DEFENCE_MULTIPLIER, + ) + val damage = + if (successful) { + val normalMax = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + val range = VestaLongswordFeintDamage.range(normalMax) + VestaLongswordFeintDamage.resolveDamage( + range = range, + isMaxHit = player.adminMaxHit, + rollInclusive = { r -> random.of(r) }, + ) + } else { + 0 + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + return true + } + } + + private companion object { + private const val VESTA_LONGSWORD_FEINT_ANIMATION: String = "seq.human_dragon_sword_spec" + private const val STAB_DEFENCE_MULTIPLIER: Double = 0.25 + } +} + +internal object VestaLongswordFeintDamage { + private const val MINIMUM_PERCENT: Int = 20 + private const val MAXIMUM_PERCENT: Int = 120 + + /** Returns the inclusive 20-120% Feint damage range for [normalMax]. */ + fun range(normalMax: Int): IntRange { + require(normalMax >= 0) { "Normal maximum hit must not be negative: $normalMax" } + val minimum = normalMax.percentOf(MINIMUM_PERCENT) + val maximum = normalMax.percentOf(MAXIMUM_PERCENT) + return minimum..maximum + } + + /** Forces the top of the range when `::maxhit` is active, same convention as elsewhere. */ + fun resolveDamage(range: IntRange, isMaxHit: Boolean, rollInclusive: (IntRange) -> Int): Int = + when { + range.first == range.last -> range.first + isMaxHit -> range.last + else -> rollInclusive(range) + } + + private fun Int.percentOf(percent: Int): Int = (toLong() * percent / 100L).toInt() +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaSpearSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaSpearSpecialAttack.kt new file mode 100644 index 000000000..ca59d2588 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaSpearSpecialAttack.kt @@ -0,0 +1,130 @@ +package org.rsmod.content.other.special.attacks.melee + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.config.constants +import org.rsmod.api.player.hit.modifier.VestaSpearCombatImmunity +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Spear Wall grants eight game cycles of melee and ranged immunity and strikes up to sixteen valid + * targets in an eight-tile square when used in a multi-combat area. Each hit has normal accuracy + * but is capped at half of the regular melee maximum hit. + * + * Both cache variants use the cache-mapped 50% special-attack energy requirement. The Bounty + * Hunter variant's successful hit uses its one-tick-faster follow-up attack delay. + */ +class VestaSpearSpecialAttack +@Inject +constructor( + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val spearWall = SpearWall(manager, targets, pvp) + registerMelee("obj.vestas_spear", spearWall) + registerMelee( + "obj.vestas_spear_bh", + SpearWall( + manager = manager, + targets = targets, + pvp = pvp, + successfulNextAttackDelay = BOUNTY_HUNTER_NEXT_ATTACK_DELAY, + ), + ) + } + + private class SpearWall( + private val manager: SpecialAttackManager, + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + private val successfulNextAttackDelay: Int? = null, + ) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = spearWall(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = spearWall(target, attack) + + private fun ProtectedAccess.spearWall( + primary: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + // These cache-native effects do not have RSCM aliases in this revision. + player.anim(RSCM.getReverseMapping(RSCMType.SEQ, VESTA_SPEAR_WALL_ANIMATION)) + soundSynth(SPEAR_WALL_SOUND) + player.spotanim( + spot = RSCM.getReverseMapping(RSCMType.SPOTANIM, VESTA_SPEAR_WALL_SPOTANIM), + height = 0, + slot = constants.spotanim_slot_combat, + ) + VestaSpearCombatImmunity.activate(player) + + val affected = + if (mapMultiway()) { + targets.select( + source = this, + primary = primary, + tiles = targets.square(player.coords, radius = AREA_RADIUS), + npcLimit = MAX_TARGETS, + playerLimit = MAX_TARGETS, + totalLimit = MAX_TARGETS, + searchZoneRadius = SEARCH_ZONE_RADIUS, + ) + } else { + listOf(primary) + } + + var totalDamage = 0 + for (target in affected) { + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = MAX_HIT_MULTIPLIER, + ) + totalDamage += damage + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + + if (target is Player && target !== primary) { + pvp.applySecondarySpecialAttack(this, target) + } + } + if (successfulNextAttackDelay != null && totalDamage > 0) { + manager.setNextAttackDelay(this, successfulNextAttackDelay) + } + manager.continueCombat(this, primary) + return true + } + } + + private companion object { + private const val VESTA_SPEAR_WALL_ANIMATION: Int = 8184 + private const val VESTA_SPEAR_WALL_SPOTANIM: Int = 1627 + private const val AREA_RADIUS: Int = 8 + private const val SEARCH_ZONE_RADIUS: Int = 2 + private const val MAX_TARGETS: Int = 16 + private const val MAX_HIT_MULTIPLIER: Double = 0.5 + private const val BOUNTY_HUNTER_NEXT_ATTACK_DELAY: Int = 4 + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + private const val SPEAR_WALL_SOUND = 2529 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VoidwakerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VoidwakerSpecialAttack.kt new file mode 100644 index 000000000..dc837dfb3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VoidwakerSpecialAttack.kt @@ -0,0 +1,93 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.statAdvance +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Disrupt is a guaranteed Magic hit worth 50?150% of the wielder's normal melee maximum hit. + * + * Its hit type intentionally remains Magic, so Magic damage mitigation?including Protect from + * Magic?continues to work even though the roll is based on the equipped sword's melee strength. + */ +class VoidwakerSpecialAttack : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val disrupt = Disrupt(manager) + registerMelee("obj.voidwaker", disrupt) + registerMelee("obj.br_voidwaker", disrupt) + registerMelee("obj.deadman_blighted_voidwaker", disrupt) + registerMelee("obj.deadman_voidwaker", disrupt) + } + + private class Disrupt(private val manager: SpecialAttackManager) : MeleeSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean { + disrupt(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + disrupt(target, attack) + return true + } + + private fun ProtectedAccess.disrupt(target: PathingEntity, attack: CombatAttack.Melee) { + anim("seq.human_special02_voidwaker") + soundSynth(DISRUPT_SOUND) + spotanim( + spot = "spotanim.fx_voidwaker02_special", + slot = constants.spotanim_slot_combat, + height = 0, + ) + target.spotanim( + spot = "spotanim.fx_voidwaker_impact", + height = 0, + slot = constants.spotanim_slot_combat, + ) + + val maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + // Deliberately do not make an accuracy roll: Disrupt always lands a Magic hit. + val damage = + if (maxHit > 0) { + random.of((maxHit / 2)..((maxHit * 3) / 2)) + } else { + 0 + } + val source = player + manager.queueMagicHit(source = this, target = target, damage = damage, clientDelay = 0) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value for the bonus xp - no impact callback needed. + if (damage > 0) { + // Voidwaker grants two Magic experience per point of damage dealt. + source.statAdvance("stat.magic", damage * MAGIC_XP_PER_DAMAGE) + } + manager.continueCombat(this, target) + } + private companion object { + const val MAGIC_XP_PER_DAMAGE = 2.0 + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val DISRUPT_SOUND = 2945 + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialAttack.kt new file mode 100644 index 000000000..629b2f8c6 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialAttack.kt @@ -0,0 +1,411 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import kotlin.math.min +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatEffects +import org.rsmod.api.combat.commons.DragonfireProtection +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.player.stat.hitpoints +import org.rsmod.api.player.stat.prayerLvl +import org.rsmod.api.player.stat.rangedLvl +import org.rsmod.api.player.stat.statHeal +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.type.getInvObj +import org.rsmod.game.type.getOrNull + +/** + * Armadyl Eye doubles this shot's accuracy and doubles an equipped enchanted bolt's base proc + * chance. The bolt effect itself is resolved through the normal ranged damage, ammo, projectile, + * and impact paths rather than being replaced by a generic special hit. + */ +class ArmadylCrossbowSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val random: GameRandom, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val armadylEye = ArmadylEye(manager, ammunition, random) + registerRanged("obj.acb", armadylEye) + registerRanged("obj.br_acb", armadylEye) + } + + private class ArmadylEye( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val random: GameRandom, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = armadylEye(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = armadylEye(target, attack) + + private fun ProtectedAccess.armadylEye( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val quiverType = getOrNull(player.quiver) + if (!ammunition.attemptAmmoUsage(player, weaponType, quiverType)) { + manager.stopCombat(this) + return false + } + + val projectileType = weaponType.paramOrNull(params.proj_type) + if (quiverType == null || projectileType == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + + anim("seq.xbows_human_fire_and_reload") + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + + val activatedBolt = + ArmadylEnchantedBolt + .from(quiverType) + ?.takeIf { it.rollsForArmadyl(random, target) } + ?.takeIf { it.isEffectiveAgainst(target) } + val damage = rangedDamage(target, attack, activatedBolt) + + val projectile = + manager.spawnProjectile( + this, + target, + ARMADYL_CROSSBOW_TRAVEL_SPOTANIM, + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), + ) + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = quiverType, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value for every bolt effect below - no impact callback needed. + activatedBolt?.applyEffect(player, target, damage) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.rangedDamage( + target: PathingEntity, + attack: CombatAttack.Ranged, + bolt: ArmadylEnchantedBolt?, + ): Int { + if (bolt == ArmadylEnchantedBolt.Ruby) { + val cost = ArmadylCrossbowSpecialDamage.rubySelfDamage(player.hitpoints) + if (cost > 0) { + // A real hit (not statSub) so a hitsplat actually shows on the shooter. The + // blood-sacrifice visual itself is played by applyEffect below, on the target. + takeInstantHit(type = HitType.Typeless, damage = cost) + } + return ArmadylCrossbowSpecialDamage.rubyDamage(target.currentHitpoints()) + } + + val bonusDamage = bolt?.bonusDamage(player, target) ?: 0 + val maxHitMultiplier = bolt?.maxHitMultiplier ?: 1.0 + return if (bolt?.bypassesAccuracy == true) { + manager.rollRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = maxHitMultiplier, + boltSpecDamage = bonusDamage, + ) + } else { + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = ARMADYL_EYE_ACCURACY_MULTIPLIER, + maxHitMultiplier = maxHitMultiplier, + boltSpecDamage = bonusDamage, + ) + } + } + } + + private companion object { + const val ARMADYL_EYE_ACCURACY_MULTIPLIER: Double = 2.0 + const val ARMADYL_CROSSBOW_TRAVEL_SPOTANIM: String = "spotanim.acb_specialattack" + } +} + +/** + * Cache-name resolver and real base proc data for enchanted crossbow bolts. Names are used here + * deliberately because this cache contains both normal and dragon versions of every enchantment. + */ +internal enum class ArmadylEnchantedBolt( + private val playerBaseChance: Double, + private val npcBaseChance: Double?, + val bypassesAccuracy: Boolean, + /** Wiki's own name for the effect, and the activation spotanim it plays on the target. */ + val spotanim: String, + val maxHitMultiplier: Double = 1.0, +) { + // "Lucky Lightning". + Opal(playerBaseChance = 0.05, npcBaseChance = 0.05, bypassesAccuracy = true, spotanim = "spotanim.xbows_lucky_lightening_strike_spot_anim"), + + // "Earth's Fury". + Jade(playerBaseChance = 0.06, npcBaseChance = 0.06, bypassesAccuracy = true, spotanim = "spotanim.xbows_earths_fury_spot_anim"), + + // "Sea Curse". + Pearl(playerBaseChance = 0.06, npcBaseChance = 0.06, bypassesAccuracy = true, spotanim = "spotanim.xbows_sea_curse_waterfall_spot_anim"), + + // "Down to Earth". + Topaz(playerBaseChance = 0.04, npcBaseChance = null, bypassesAccuracy = true, spotanim = "spotanim.xbows_down_to_earth_spot_anim"), + + // "Clear Mind". + Sapphire(playerBaseChance = 0.05, npcBaseChance = 0.25, bypassesAccuracy = true, spotanim = "spotanim.xbows_clear_mind_glowing_spot_anim"), + + // "Magical Poison". + Emerald(playerBaseChance = 0.54, npcBaseChance = 0.55, bypassesAccuracy = false, spotanim = "spotanim.xbows_magical_poison_spot_anim"), + + // "Blood Forfeit". + Ruby(playerBaseChance = 0.11, npcBaseChance = 0.06, bypassesAccuracy = true, spotanim = "spotanim.xbows_blood_sacrifice_spot_anim"), + + // "Armour Piercing". + Diamond( + playerBaseChance = 0.05, + npcBaseChance = 0.10, + bypassesAccuracy = true, + spotanim = "spotanim.xbows_diamond_tips_spotanim", + maxHitMultiplier = 1.15, + ), + + // "Dragon's Breath". + Dragonstone(playerBaseChance = 0.06, npcBaseChance = 0.06, bypassesAccuracy = false, spotanim = "spotanim.xbows_dragons_breath_spot_anim"), + + // "Life Leech". + Onyx( + playerBaseChance = 0.10, + npcBaseChance = 0.11, + bypassesAccuracy = false, + spotanim = "spotanim.xbows_life_leach_spot_anim", + maxHitMultiplier = 1.20, + ), + ; + + fun armadylActivationChance(againstPlayer: Boolean): Double = + 2.0 * if (againstPlayer) playerBaseChance else npcBaseChance.orEmpty() + + fun rollsForArmadyl( + random: GameRandom, + target: PathingEntity, + ): Boolean = random.randomDouble() < armadylActivationChance(target is Player) + + fun isEffectiveAgainst(target: PathingEntity): Boolean = + when (this) { + Topaz, Sapphire -> target is Player + Dragonstone -> + target !is Npc || + ( + (target.visType.paramOrNull(params.draconic) ?: 0) == 0 && + (target.visType.paramOrNull(params.elemental_weakness_type) ?: -1) != + constants.elemental_weakness_water + ) + + Onyx -> + target !is Npc || (target.visType.paramOrNull(params.undead) ?: 0) == 0 + + else -> true + } + + fun bonusDamage( + source: Player, + target: PathingEntity, + ): Int = + when (this) { + Opal -> ArmadylCrossbowSpecialDamage.opalBonus(source.rangedLvl) + Pearl -> + ArmadylCrossbowSpecialDamage.pearlBonus( + source.rangedLvl, + fieryTarget = target.isFiery(), + ) + + Dragonstone -> + ArmadylCrossbowSpecialDamage.dragonstoneBonus( + source.rangedLvl, + target = target, + ) + + else -> 0 + } + + /** + * Real OSRS doesn't clamp damage after the roll, so [damage] (the already-known, pre-clamp + * value) is the authentic figure for every bolt effect below - no impact callback needed. + */ + fun applyEffect(source: Player, target: PathingEntity, damage: Int) { + if (damage > 0) { + target.spotanim(spot = spotanim, height = 0) + } + when (this) { + Jade -> { + val player = target as? Player ?: return + if (damage > 0) { + CombatEffects.freeze(player, JADE_FREEZE_TICKS) + } + } + + Topaz -> { + val player = target as? Player ?: return + if (damage > 0) { + player.statSub("stat.magic", constant = TOPAZ_MAGIC_DRAIN, percent = 0) + } + } + + Sapphire -> { + val player = target as? Player ?: return + if (damage <= 0) { + return + } + val requested = ArmadylCrossbowSpecialDamage.sapphirePrayerDrain(source.rangedLvl) + val drained = min(requested, player.prayerLvl) + if (drained <= 0) { + return + } + player.statSub("stat.prayer", constant = drained, percent = 0) + source.statHeal( + "stat.prayer", + constant = ArmadylCrossbowSpecialDamage.sapphirePrayerRestore(drained), + percent = 0, + ) + } + + Emerald -> { + val player = target as? Player ?: return + if (damage > 0) { + CombatEffects.poison(player, EMERALD_POISON_DAMAGE) + } + } + + Onyx -> { + if (damage > 0 && source.hitpoints > 0) { + source.statHeal( + "stat.hitpoints", + constant = ArmadylCrossbowSpecialDamage.onyxHeal(damage), + percent = 0, + ) + } + } + + else -> {} + } + } + + companion object { + const val JADE_FREEZE_TICKS: Int = 8 + const val TOPAZ_MAGIC_DRAIN: Int = 1 + const val EMERALD_POISON_DAMAGE: Int = 5 + + fun from(ammo: ItemServerType): ArmadylEnchantedBolt? = fromName(ammo.lowercaseName) + + fun fromName(name: String): ArmadylEnchantedBolt? { + val lower = name.lowercase() + if (!lower.contains("bolts (e)")) { + return null + } + return when { + lower.startsWith("opal ") -> Opal + lower.startsWith("jade ") -> Jade + lower.startsWith("pearl ") -> Pearl + lower.startsWith("topaz ") -> Topaz + lower.startsWith("sapphire ") -> Sapphire + lower.startsWith("emerald ") -> Emerald + lower.startsWith("ruby ") -> Ruby + lower.startsWith("diamond ") -> Diamond + lower.startsWith("dragonstone ") -> Dragonstone + lower.startsWith("onyx ") -> Onyx + else -> null + } + } + + private fun Double?.orEmpty(): Double = this ?: 0.0 + } +} + +/** Small pure helpers used by Armadyl Eye's bolt effects and their focused tests. */ +internal object ArmadylCrossbowSpecialDamage { + fun rubyDamage(targetHitpoints: Int): Int = min(targetHitpoints / 5, RUBY_DAMAGE_CAP) + + fun rubySelfDamage(sourceHitpoints: Int): Int = sourceHitpoints / 10 + + fun opalBonus(visibleRangedLevel: Int): Int = visibleRangedLevel / 10 + + fun pearlBonus( + visibleRangedLevel: Int, + fieryTarget: Boolean, + ): Int = visibleRangedLevel / if (fieryTarget) 15 else 20 + + fun dragonstoneBonus( + visibleRangedLevel: Int, + target: PathingEntity, + ): Int { + val base = visibleRangedLevel / 5 + return if (target is Player) { + DragonfireProtection.resolveMaxHit( + player = target, + type = DragonfireProtection.DragonfireType.Chromatic, + baseMax = base, + ) + } else { + base + } + } + + fun sapphirePrayerDrain(visibleRangedLevel: Int): Int = visibleRangedLevel / 20 + + fun sapphirePrayerRestore(drainedPrayer: Int): Int = drainedPrayer / 2 + + fun onyxHeal(damage: Int): Int = damage / 4 + + private const val RUBY_DAMAGE_CAP: Int = 100 +} + +private fun PathingEntity.currentHitpoints(): Int = + when (this) { + is Npc -> hitpoints + is Player -> hitpoints + } + +private fun PathingEntity.isFiery(): Boolean = + this is Npc && + ( + (visType.paramOrNull(params.draconic) ?: 0) != 0 || + (visType.paramOrNull(params.elemental_weakness_type) ?: -1) == + constants.elemental_weakness_water + ) diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaSpecialAttack.kt new file mode 100644 index 000000000..845d70358 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaSpecialAttack.kt @@ -0,0 +1,128 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj +import org.rsmod.game.type.getOrNull + +/** Concentrated shot: one ballista javelin with 25% more accuracy and maximum damage. */ +class BallistaSpecialAttack @Inject constructor(private val ammunition: RangedAmmoManager) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val concentratedShot = Ballista(manager, ammunition, ornamented = false) + registerRanged("obj.light_ballista", concentratedShot) + registerRanged("obj.br_light_ballista", concentratedShot) + registerRanged("obj.heavy_ballista", concentratedShot) + registerRanged("obj.br_heavy_ballista", concentratedShot) + registerRanged("obj.heavy_ballista_ornament", Ballista(manager, ammunition, ornamented = true)) + } + + private class Ballista( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val ornamented: Boolean, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = concentratedShot(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = concentratedShot(target, attack) + + private fun ProtectedAccess.concentratedShot( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val quiverType = getOrNull(player.quiver) + if (!ammunition.attemptAmmoUsage(player, weaponType, quiverType)) { + manager.stopCombat(this) + return false + } + + val travelSpotanim = quiverType?.paramOrNull(params.proj_travel) + val projectileType = weaponType.paramOrNull(params.proj_type) + if (travelSpotanim == null || projectileType == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + + anim(BallistaAnimation.resolve(ornamented = ornamented, targetIsNpc = target is Npc)) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + spotanim("spotanim.ballista_special", height = 0, slot = constants.spotanim_slot_combat) + + val launchSpot = + quiverType.paramOrNull(params.proj_launch)?.let { + RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) + } + spotanim(launchSpot, height = 0, slot = constants.spotanim_slot_combat) + + val projectile = + manager.spawnProjectile( + this, + target, + RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id), + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), + ) + val damage = + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = CONCENTRATED_SHOT_MULTIPLIER, + maxHitMultiplier = CONCENTRATED_SHOT_MULTIPLIER, + ) + + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = quiverType, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val CONCENTRATED_SHOT_MULTIPLIER = 1.25 + } +} + +/** Pure animation selection, kept separate from [ProtectedAccess] so it can be unit tested. */ +internal object BallistaAnimation { + fun resolve(ornamented: Boolean, targetIsNpc: Boolean): String = + when { + ornamented && targetIsNpc -> "seq.ballista02_special_attack_pvn" + ornamented -> "seq.ballista02_special_attack" + targetIsNpc -> "seq.ballista_special_attack_pvn" + else -> "seq.ballista_special_attack" + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowSpecialAttack.kt index 1bdc69ebb..2ae230832 100644 --- a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowSpecialAttack.kt +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowSpecialAttack.kt @@ -3,7 +3,6 @@ package org.rsmod.content.other.special.attacks.ranged import dev.openrune.rscm.RSCM import dev.openrune.rscm.RSCMType import dev.openrune.types.ItemServerType -import dev.openrune.types.aconverted.SpotanimType import jakarta.inject.Inject import org.rsmod.api.combat.commons.CombatAttack import org.rsmod.api.combat.manager.RangedAmmoManager @@ -30,6 +29,9 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm registerRanged("obj.darkbow_yellow", DarkBow(manager, ammunition)) registerRanged("obj.darkbow_white", DarkBow(manager, ammunition)) registerRanged("obj.bh_darkbow_imbue", DarkBow(manager, ammunition)) + registerRanged("obj.br_darkbow", DarkBow(manager, ammunition)) + registerRanged("obj.deadman_blighted_dark_bow", DarkBow(manager, ammunition)) + registerRanged("obj.deadman_darkbow", DarkBow(manager, ammunition)) } private class DarkBow( @@ -76,12 +78,12 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm val descentOfDragons = quiverType.isCategoryType("category.dragon_arrow") if (descentOfDragons) { - descentOfDragons(target, attack, quiverType, RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id)) + descentOfDragons(target, attack, quiverType) manager.continueCombat(this, target) return true } - descentOfDarkness(target, attack, quiverType, RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id)) + descentOfDarkness(target, attack, quiverType) manager.continueCombat(this, target) return true } @@ -90,32 +92,41 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm target: PathingEntity, attack: CombatAttack.Ranged, quiverType: ItemServerType, - travelSpot: String, ) { val launchSpot = quiverType.paramOrNull(params.proj_launch_double) - anim("seq.human_bow") + anim(DARK_BOW_FIRE_SEQUENCE) soundSynth("synth.darkbow_doublefire") soundSynth("synth.darkbow_shadow_attack") - spotanim(RSCM.getReverseMapping(RSCMType.SPOTANIM,launchSpot!!.id), height = 96, slot = constants.spotanim_slot_combat) + spotanim(RSCM.getReverseMapping(RSCMType.SPOTANIM, launchSpot!!.id), height = 96, slot = constants.spotanim_slot_combat) val descentTravel = "spotanim.darkbow_generic_smoke_arrow_flight" val descentImpact = "spotanim.darkbow_smoke_arrow_impact" val impactSynth = "synth.darkbow_shadow_impact" - manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_one") - val proj1 = manager.spawnProjectile(this, target, travelSpot, "projanim.doublearrow_one") + // Was spawning two overlapping projectiles per arrow - this special one PLUS a second, + // separate one using the ammo's own plain proj_travel colour, both flying the same + // path at once. Live testing confirmed that reads as "the wrong arrow in the air" + // regardless of ammo, since the plain one renders on top of/alongside the special one. + // The ammo's travel spotanim is no longer resolved/passed in at all - calculateEndTime + // (and so clientCycles/serverCycles) depends only on the projanim type, not the + // spotanim, so dropping the second spawn doesn't change any of the timing below. + val proj1 = manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_one") val clientDelay1 = proj1.clientCycles manager.soundArea(target, impactSynth, delay = clientDelay1, radius = 10) - manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_two") - val proj2 = manager.spawnProjectile(this, target, travelSpot, "projanim.doublearrow_two") + val proj2 = manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_two") val clientDelay2 = proj2.clientCycles manager.soundArea(target, impactSynth, delay = clientDelay2, radius = 10) target.spotanim(descentImpact, height = 96, delay = clientDelay2) val damage = - calculateDamage(target, attack, damageRange = 5..Int.MAX_VALUE, multiplier = 1.3) + calculateDamage( + target, + attack, + damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, + multiplier = DarkBowDamage.DESCENT_OF_DARKNESS_MULTIPLIER, + ) val hitDelay1 = proj1.serverCycles val hitDelay2 = proj2.serverCycles @@ -137,7 +148,13 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm dropDelay = hitDelay2, ) - manager.queueRangedDamage(this, target, quiverType, damage[1], hitDelay2) + // Both hits resolve on the first arrow's tick (hitDelay1), not the second arrow's own + // (slightly later) serverCycles - doublearrow_one/two intentionally have different + // stepMultiplier values in projectiles.toml for the visual high/low arc, which grows + // with distance and was causing the two hits to land a tick apart at real combat range. + // Same fix as Magic shortbow's Snapshot: the arc/offset is purely visual, not a real + // gameplay delay between the two hits. + manager.queueRangedDamage(this, target, quiverType, damage[1], hitDelay1) if (player.quiver?.count == 1) { mes("You now have only 1 arrow left in your quiver.") @@ -148,31 +165,41 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm target: PathingEntity, attack: CombatAttack.Ranged, quiverType: ItemServerType, - travelSpot: String, ) { val launchSpot = quiverType.paramOrNull(params.proj_launch_double) - anim("seq.human_bow") + anim(DARK_BOW_FIRE_SEQUENCE) soundSynth("synth.darkbow_doublefire") soundSynth("synth.darkbow_dragon_attack") - spotanim(RSCM.getReverseMapping(RSCMType.SPOTANIM,launchSpot!!.id), height = 96, slot = constants.spotanim_slot_combat) + spotanim(RSCM.getReverseMapping(RSCMType.SPOTANIM, launchSpot!!.id), height = 96, slot = constants.spotanim_slot_combat) val descentTravel = "spotanim.darkbow_dragon_head_flying_projanim" val descentImpact = "spotanim.darkbow_dragon_head_flying_impact_anim" val impactSynth = "synth.darkbow_shadow_impact" - manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_one") - val proj1 = manager.spawnProjectile(this, target, travelSpot, "projanim.doublearrow_one") + // Was spawning two overlapping projectiles per arrow - this special one PLUS a second, + // separate one using the ammo's own plain proj_travel colour, both flying the same + // path at once. Live testing confirmed that reads as "the wrong arrow in the air" + // regardless of ammo, since the plain one renders on top of/alongside the special one. + // The ammo's travel spotanim is no longer resolved/passed in at all - calculateEndTime + // (and so clientCycles/serverCycles) depends only on the projanim type, not the + // spotanim, so dropping the second spawn doesn't change any of the timing below. + val proj1 = manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_one") val clientDelay1 = proj1.clientCycles manager.soundArea(target, impactSynth, delay = clientDelay1, radius = 10) - manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_two") - val proj2 = manager.spawnProjectile(this, target, travelSpot, "projanim.doublearrow_two") + val proj2 = manager.spawnProjectile(this, target, descentTravel, "projanim.doublearrow_two") val clientDelay2 = proj2.clientCycles manager.soundArea(target, impactSynth, delay = clientDelay2, radius = 10) target.spotanim(descentImpact, height = 96, delay = clientDelay2) - val damage = calculateDamage(target, attack, damageRange = 8..48, multiplier = 1.5) + val damage = + calculateDamage( + target, + attack, + damageRange = DarkBowDamage.DESCENT_OF_DRAGONS_RANGE, + multiplier = DarkBowDamage.DESCENT_OF_DRAGONS_MULTIPLIER, + ) val hitDelay1 = proj1.serverCycles val hitDelay2 = proj2.serverCycles @@ -194,7 +221,13 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm dropDelay = hitDelay2, ) - manager.queueRangedDamage(this, target, quiverType, damage[1], hitDelay2) + // Both hits resolve on the first arrow's tick (hitDelay1), not the second arrow's own + // (slightly later) serverCycles - doublearrow_one/two intentionally have different + // stepMultiplier values in projectiles.toml for the visual high/low arc, which grows + // with distance and was causing the two hits to land a tick apart at real combat range. + // Same fix as Magic shortbow's Snapshot: the arc/offset is purely visual, not a real + // gameplay delay between the two hits. + manager.queueRangedDamage(this, target, quiverType, damage[1], hitDelay1) if (player.quiver?.count == 1) { mes("You now have only 1 arrow left in your quiver.") @@ -226,8 +259,18 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm multiplier = multiplier, boltSpecDamage = 0, ) - val first = if (!accuracySuccess()) 0 else random.of(0..damage).coerceIn(damageRange) - val second = if (!accuracySuccess()) 0 else random.of(0..damage).coerceIn(damageRange) + val first = + DarkBowDamage.resolveHit( + accuracySuccess = accuracySuccess(), + damageRange = damageRange, + rollRawDamage = { random.of(0..damage) }, + ) + val second = + DarkBowDamage.resolveHit( + accuracySuccess = accuracySuccess(), + damageRange = damageRange, + rollRawDamage = { random.of(0..damage) }, + ) return DescentHit(first, second) } @@ -243,4 +286,25 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm } } } + + private companion object { + const val DARK_BOW_FIRE_SEQUENCE = "seq.human_bow" + } +} + +/** + * Pure per-hit damage math for Descent of Darkness/Dragons, kept separate from [ProtectedAccess] + * so the range clamping can be unit tested directly instead of only through a live combat roll. + */ +internal object DarkBowDamage { + /** Wiki: both variants cap each hit at 48; Darkness also has a 5 damage floor. */ + val DESCENT_OF_DARKNESS_RANGE = 5..48 + const val DESCENT_OF_DARKNESS_MULTIPLIER = 1.3 + + /** Wiki: both variants cap each hit at 48; Dragons also has an 8 damage floor. */ + val DESCENT_OF_DRAGONS_RANGE = 8..48 + const val DESCENT_OF_DRAGONS_MULTIPLIER = 1.5 + + fun resolveHit(accuracySuccess: Boolean, damageRange: IntRange, rollRawDamage: () -> Int): Int = + if (!accuracySuccess) 0 else rollRawDamage().coerceIn(damageRange) } diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DorgeshuunCrossbowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DorgeshuunCrossbowSpecialAttack.kt new file mode 100644 index 000000000..d32494e97 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DorgeshuunCrossbowSpecialAttack.kt @@ -0,0 +1,163 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.player.stat.stat +import org.rsmod.api.player.stat.statBase +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj +import org.rsmod.game.type.getOrNull + +/** + * Snipe: a normal bone-bolt shot that cannot miss if this player was not the target's most recent + * source of positive final damage. A landed shot drains Defence by the final damage dealt, only + * while the target's Defence has not already been reduced below its base level. + */ +class DorgeshuunCrossbowSpecialAttack @Inject constructor(private val ammunition: RangedAmmoManager) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerRanged("obj.dttd_bone_crossbow", Snipe(manager, ammunition)) + } + + private class Snipe( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = snipe(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = snipe(target, attack) + + private fun ProtectedAccess.snipe( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val quiverType = + getOrNull(player.quiver) + ?: run { + manager.stopCombat(this) + mes("There is no ammo left in your quiver.") + return false + } + if (!ammunition.attemptAmmoUsage(player, weaponType, quiverType)) { + manager.stopCombat(this) + return false + } + + val projectileType = weaponType.paramOrNull(params.proj_type) + if (projectileType == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + + when (target) { + is Npc -> anim("seq.dttd_player_fire_bone_crossbow_pvn") + is Player -> anim("seq.dttd_player_fire_bone_crossbow") + } + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + spotanim( + "spotanim.bone_crossbow_launch", + height = 0, + slot = constants.spotanim_slot_combat, + ) + + val projectile = + manager.spawnProjectile( + this, + target, + SNIPE_TRAVEL_SPOTANIM, + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), + ) + val successful = target.isUnsuspecting(player) + || manager.rollRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + val damage = + if (successful) { + manager.rollRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + boltSpecDamage = 0, + ) + } else { + 0 + } + + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = quiverType, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // Real OSRS doesn't clamp damage after the roll, so the already-known pre-clamp + // damage is the authentic value for the Defence drain - no impact callback needed. + if (damage > 0) { + reduceDefence(target, damage) + } + manager.continueCombat(this, target) + return true + } + + private fun PathingEntity.isUnsuspecting(source: Player): Boolean { + val sourceUuid = source.uuid + return sourceUuid == null || lastDamagingPlayerUuid != sourceUuid + } + } + + private companion object { + const val SNIPE_TRAVEL_SPOTANIM = "spotanim.dttd_bone_crossbowbolt_travel_sp_attack" + } +} + +private fun reduceDefence(target: PathingEntity, damage: Int) { + when (target) { + is Player -> { + if (target.stat("stat.defence") >= target.statBase("stat.defence")) { + target.statSub("stat.defence", constant = damage, percent = 0) + } + } + is Npc -> { + if (target.defenceLvl >= target.baseDefenceLvl) { + target.defenceLvl = (target.defenceLvl - damage).coerceAtLeast(0) + } + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialAttack.kt new file mode 100644 index 000000000..79a3b351b --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialAttack.kt @@ -0,0 +1,174 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.content.other.special.attacks.melee.AreaMeleeTargetSelector +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getOrNull + +/** + * Annihilate fires one bolt at the selected target. In a multi-combat area, that one successful + * roll also damages every eligible entity in the target's 3x3 square: +20% to the primary and + * -20% to all secondary targets. Enchanted-bolt effects intentionally do not occur. + */ +class DragonCrossbowSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val annihilate = Annihilate(manager, ammunition, targets, pvp) + registerRanged("obj.xbows_crossbow_dragon", annihilate) + registerRanged("obj.bh_xbows_crossbow_dragon_corrupted", annihilate) + registerRanged("obj.br_xbows_crossbow_dragon", annihilate) + } + + private class Annihilate( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val targets: AreaMeleeTargetSelector, + private val pvp: PvPAreaAttackManager, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = annihilate(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = annihilate(target, attack) + + private fun ProtectedAccess.annihilate( + primary: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = + requireNotNull( + ServerCacheManager.getItem( + "obj.xbows_crossbow_dragon".asRSCM(RSCMType.OBJ) + ) + ) + val quiverType = getOrNull(player.quiver) + if (!ammunition.attemptAmmoUsage(player, weaponType, quiverType)) { + manager.stopCombat(this) + return false + } + + val projectileType = weaponType.paramOrNull(params.proj_type) + val travelSpotanim = quiverType?.paramOrNull(params.proj_travel) + if (quiverType == null || projectileType == null || travelSpotanim == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + // Dedicated fire-and-reload sequence, no caster-side spotanim. + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + anim("seq.xbows_human_fire_and_reload") + + val affected = + if (mapMultiway()) { + targets.select( + source = this, + primary = primary, + tiles = targets.square(primary.coords, radius = 1), + npcLimit = MAX_TARGETS, + playerLimit = MAX_TARGETS, + totalLimit = MAX_TARGETS, + ) + } else { + listOf(primary) + } + + // Annihilate performs one ordinary accuracy/damage roll against the selected target. + // The resulting raw hit is shared by the area rather than re-rolled per victim. + val rawDamage = + manager.rollRangedDamage( + source = this, + target = primary, + attack = attack, + ) + // Confirmed via the same reference: the bolt itself uses a dedicated special-attack + // travel glow, not the ammo's own plain colour (`travelSpotanim`, still needed above + // only to validate the ammo is real). + val projectileTypeName = + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id) + val projectiles = + affected.associateWith { target -> + manager.spawnProjectile( + this, + target, + ANNIHILATE_TRAVEL_SPOTANIM, + projectileTypeName, + ) + } + + val primaryProjectile = requireNotNull(projectiles[primary]) + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = primary.coords, + dropDelay = primaryProjectile.serverCycles, + ) + + for (target in affected) { + val damage = + if (target === primary) { + DragonCrossbowSpecialDamage.primary(rawDamage) + } else { + DragonCrossbowSpecialDamage.secondary(rawDamage) + } + val projectile = requireNotNull(projectiles[target]) + target.spotanim( + spot = ANNIHILATE_IMPACT_SPOTANIM, + height = 92, + delay = projectile.clientCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = quiverType, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + if (target is Player && target !== primary) { + pvp.applySecondarySpecialAttack(this, target) + } + } + manager.continueCombat(this, primary) + return true + } + } + + private companion object { + const val MAX_TARGETS: Int = 10 + const val ANNIHILATE_TRAVEL_SPOTANIM = "spotanim.dttd_bone_crossbowbolt_travel_sp_attack" + const val ANNIHILATE_IMPACT_SPOTANIM = "spotanim.firesurge_impact" + } +} + +/** Integer-floor damage scaling for Annihilate's primary and surrounding targets. */ +internal object DragonCrossbowSpecialDamage { + fun primary(rawDamage: Int): Int = rawDamage.coerceAtLeast(0) * 6 / 5 + + fun secondary(rawDamage: Int): Int = rawDamage.coerceAtLeast(0) * 4 / 5 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeSpecialAttack.kt new file mode 100644 index 000000000..9a0e61e13 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeSpecialAttack.kt @@ -0,0 +1,136 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.refs.params +import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** Duality: throws two dragon knives, each with a separate normal ranged damage roll. */ +class DragonKnifeSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val poison: WeaponPoisonEffect, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val duality = DragonKnife(manager, ammunition, poison) + registerRanged("obj.dragon_knife", duality) + registerRanged("obj.dragon_knife_p", duality) + registerRanged("obj.dragon_knife_p+", duality) + registerRanged("obj.dragon_knife_p++", duality) + registerRanged("obj.br_dragon_knife", duality) + } + + private class DragonKnife( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val poison: WeaponPoisonEffect, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = duality(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = duality(target, attack) + + private fun ProtectedAccess.duality( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + if ((player.righthand?.count ?: 0) < KNIVES_PER_SPECIAL) { + manager.stopCombat(this) + mes("You need at least 2 dragon knives equipped to use this special attack.") + return false + } + + val poisoned = DragonKnifeVariant.isPoisoned(weaponType.name) + val travelSpot = + if (poisoned) POISONED_SPECIAL_TRAVEL_SPOTANIM else SPECIAL_TRAVEL_SPOTANIM + val projanim = + weaponType.paramOrNull(params.proj_type)?.let { + RSCM.getReverseMapping(RSCMType.PROJANIM, it.id) + } ?: THROWN_PROJANIM + + anim(if (poisoned) POISONED_SPECIAL_ANIM else SPECIAL_ANIM) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + + val firstProjectile = manager.spawnProjectile(this, target, travelSpot, projanim) + val secondProjectile = manager.spawnProjectile(this, target, travelSpot, projanim) + val firstDamage = manager.rollRangedDamage(this, target, attack) + val secondDamage = manager.rollRangedDamage(this, target, attack) + + manager.giveCombatXp(this, target, attack, firstDamage + secondDamage) + + ammunition.useThrownWeapon( + player = player, + weaponType = weaponType, + dropCoord = target.coords, + dropDelay = firstProjectile.serverCycles, + ) + manager.queueRangedHit( + source = this, + target = target, + ammo = null, + damage = firstDamage, + clientDelay = secondProjectile.clientCycles, + hitDelay = firstProjectile.serverCycles, + ) + // Each thrown knife is its own independent roll for weapon poison, same as any other + // poisoned ammo - this variant already detected "poisoned" for its visuals (see + // DragonKnifeVariant) but never actually applied the poison itself. + poison.rollOnRangedHit(player, target, weaponType, firstDamage) + + ammunition.useThrownWeapon( + player = player, + weaponType = weaponType, + dropCoord = target.coords, + dropDelay = secondProjectile.serverCycles, + ) + manager.queueRangedDamage( + source = this, + target = target, + ammo = null, + damage = secondDamage, + hitDelay = secondProjectile.serverCycles, + ) + poison.rollOnRangedHit(player, target, weaponType, secondDamage) + + if (player.righthand == null) { + mes("That was your last one!") + } + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val KNIVES_PER_SPECIAL = 2 + const val SPECIAL_ANIM = "seq.human_dragon_tknives_spec" + const val POISONED_SPECIAL_ANIM = "seq.human_dragon_tknives_spec_poison" + const val SPECIAL_TRAVEL_SPOTANIM = "spotanim.dragon_tknife_travel_spec" + const val POISONED_SPECIAL_TRAVEL_SPOTANIM = "spotanim.dragon_tknife_travel_spec_p" + const val THROWN_PROJANIM = "projanim.thrown" + } +} + +/** Pure variant detection, kept separate from [ProtectedAccess] so it can be unit tested. */ +internal object DragonKnifeVariant { + fun isPoisoned(itemName: String): Boolean = itemName.contains("(p", ignoreCase = true) +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonThrownaxeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonThrownaxeSpecialAttack.kt new file mode 100644 index 000000000..bce82127c --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonThrownaxeSpecialAttack.kt @@ -0,0 +1,104 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.NextCycleRangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** Momentum Throw: one normal-damage thrown axe with 25% accuracy, dispatched next cycle. */ +class DragonThrownaxeSpecialAttack @Inject constructor(private val ammunition: RangedAmmoManager) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val momentumThrow = MomentumThrow(manager, ammunition) + registerRanged("obj.dragon_thrownaxe", momentumThrow) + registerRanged("obj.br_dragon_thrownaxe", momentumThrow) + } + + private class MomentumThrow( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + ) : NextCycleRangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = momentumThrow(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = momentumThrow(target, attack) + + private fun ProtectedAccess.momentumThrow( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + if (!ammunition.attemptAmmoUsage(player, weaponType, ammo = null)) { + manager.stopCombat(this) + return false + } + + val travelSpotanim = SPECIAL_TRAVEL_SPOTANIM + val projectileType = + weaponType.paramOrNull(params.proj_type)?.let { + RSCM.getReverseMapping(RSCMType.PROJANIM, it.id) + } ?: DEFAULT_PROJANIM + anim(SPECIAL_ANIM) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + spotanim(SPECIAL_LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) + + val projectile = manager.spawnProjectile(this, target, travelSpotanim, projectileType) + val damage = + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = MOMENTUM_THROW_ACCURACY, + ) + + ammunition.useThrownWeapon( + player = player, + weaponType = weaponType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = null, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + + if (player.righthand == null) { + mes("That was your last one!") + return true + } + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val MOMENTUM_THROW_ACCURACY = 1.25 + const val SPECIAL_ANIM = "seq.human_dragon_taxe_spec" + const val SPECIAL_TRAVEL_SPOTANIM = "spotanim.dragon_taxe_travel_spec" + const val SPECIAL_LAUNCH_SPOTANIM = "spotanim.dragon_taxe_launch_spec" + const val DEFAULT_PROJANIM = "projanim.thrown" + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlSpecialAttack.kt new file mode 100644 index 000000000..047dddde3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlSpecialAttack.kt @@ -0,0 +1,142 @@ +package org.rsmod.content.other.special.attacks.ranged + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.config.constants +import org.rsmod.api.mechanics.toxins.BurnEffectService +import org.rsmod.api.player.hat +import org.rsmod.api.player.legs +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.player.torso +import org.rsmod.api.player.worn.EquipmentChecks +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Eclipse is a point-blank, Magic-based ranged hit. It consumes remaining Burn damage even if the + * accuracy roll fails, turning it into a capped minimum and maximum damage increase for this hit. + */ +class EclipseAtlatlSpecialAttack +@Inject +constructor( + private val burns: BurnEffectService, + private val random: GameRandom, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val eclipse = Eclipse(manager, burns, random) + registerRanged("obj.eclipse_atlatl", eclipse) + registerRanged("obj.br_eclipse_atlatl", eclipse) + } + + private class Eclipse( + private val manager: SpecialAttackManager, + private val burns: BurnEffectService, + private val random: GameRandom, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = eclipse(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = eclipse(target, attack) + + private fun ProtectedAccess.eclipse( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + if (!player.isWearingEclipseMoonSet()) { + mes("You need to be wearing the full Eclipse Moon armour set to use this special attack.") + manager.stopCombat(this) + return false + } + if (distanceTo(target) > MELEE_DISTANCE) { + mes("You need to be within melee distance of your target to use this special attack.") + manager.stopCombat(this) + return false + } + + anim("seq.human_special_atlatl_01") + spotanim( + spot = "spotanim.special_atlatl_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + + val normalMax = + manager.calculateRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + boltSpecDamage = 0, + ) + val hitRange = + EclipseAtlatlDamage.hitRange( + normalMax = normalMax, + remainingBurn = burns.consumeRemainingDamage(target), + ) + val damage = + if ( + manager.rollMagicalRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = ACCURACY_MULTIPLIER, + ) + ) { + random.of(hitRange) + } else { + 0 + } + + // The hit is Magic-type for protection prayers, but the atlatl still trains Ranged. + manager.giveCombatXp(this, target, attack, damage) + manager.queueMagicHit( + source = this, + target = target, + damage = damage, + clientDelay = 0, + ) + target.spotanim( + spot = "spotanim.special_atlatl_impact_spotanim", + slot = constants.spotanim_slot_combat, + height = 0, + ) + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val MELEE_DISTANCE: Int = 1 + const val ACCURACY_MULTIPLIER: Double = 1.5 + } +} + +/** Pure Burn-to-hit-range conversion used by Eclipse. */ +internal object EclipseAtlatlDamage { + fun hitRange(normalMax: Int, remainingBurn: Int): IntRange { + require(normalMax >= 0) { + "normalMax must not be negative. (normalMax=$normalMax)" + } + val burnBonus = remainingBurn.coerceIn(0, MAX_BURN_DAMAGE) + return (burnBonus / 2)..(normalMax + burnBonus) + } + + private const val MAX_BURN_DAMAGE: Int = 50 +} + +internal fun Player.isWearingEclipseMoonSet(): Boolean = + EquipmentChecks.isEclipseMoonSet(hat, torso, legs, righthand) diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowSpecialAttack.kt new file mode 100644 index 000000000..a166d6d43 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowSpecialAttack.kt @@ -0,0 +1,155 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj +import org.rsmod.game.type.getOrNull + +/** Powershot: a normal-damage arrow that bypasses the ranged accuracy roll. */ +class MagicBowSpecialAttack @Inject constructor(private val ammunition: RangedAmmoManager) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val powershot = MagicBow(manager, ammunition) + registerRanged("obj.magic_longbow", powershot) + registerRanged("obj.trail_composite_bow_magic", powershot) + } + + private class MagicBow( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = powershot(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = powershot(target, attack) + + private fun ProtectedAccess.powershot( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val quiverType = getOrNull(player.quiver) + if (!ammunition.attemptAmmoUsage(player, weaponType, quiverType)) { + manager.stopCombat(this) + return false + } + + val travelSpotanim = quiverType?.paramOrNull(params.proj_travel) + val projectileType = weaponType.paramOrNull(params.proj_type) + if (travelSpotanim == null || projectileType == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + if (!playRangedWeaponFx(weaponType)) { + manager.stopCombat(this) + mes("The bow fails to fire.") + return false + } + soundSynth(POWERSHOT_SOUND) + + // `spotanim.sp_attack_glow_arrow_launch` (250) is Powershot's own graphic - every other + // id in this project's spec-graphic block (246-258) is already claimed by a different + // special (Snapshot's 256/249, Rune thrownaxe's 257/258, Dragon dagger/mace/hasta's + // 252/251/253/254, etc.), leaving 250 unused by elimination. Overrides the ammo's plain + // proj_launch/proj_travel entirely, same pattern as Snapshot's fix - live testing + // confirmed the ammo's default colours showed instead of the green Powershot glow. + spotanim(POWERSHOT_GLOW_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) + + val projectile = + manager.spawnProjectile( + this, + target, + POWERSHOT_GLOW_SPOTANIM, + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), + ) + + // Powershot deliberately skips rollRangedDamage (no accuracy roll needed - guaranteed + // to hit) and also can't use rollRangedMaxHit/calculateRangedMaxHit: those apply the + // standard formula (gear ranged strength, prayer/void boosts, Slayer helmet(i) etc.), + // but the wiki documents Powershot as a custom, stripped-down formula that ignores all + // of that - only visible Ranged level and the ammo's own ranged strength matter. Same + // formula and constants as Magic shortbow's Snapshot special in this same package. + val maxHit = + MagicBowDamage.maxHit( + rangedLevel = stat("stat.ranged"), + ammoRangedStrength = quiverType.param(params.ranged_strength), + ) + val damage = + MagicBowDamage.resolveDamage( + maxHit = maxHit, + isMaxHit = player.adminMaxHit, + rollInclusive = { range -> random.of(range) }, + ) + + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = quiverType, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val POWERSHOT_GLOW_SPOTANIM = "spotanim.sp_attack_glow_arrow_launch" + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val POWERSHOT_SOUND = 2536 + } +} + +/** + * Pure Powershot max-hit math, kept separate from [ProtectedAccess] so the formula can be unit + * tested directly against the wiki's stated formula instead of only through a live combat roll. + */ +internal object MagicBowDamage { + /** + * Wiki: `Maximum Hit = floor(0.5 + (Visible Ranged Level + 10) * (Ammo Ranged Strength + 64) + * / 640)`. `(n + 320) / 640` under integer division is exactly that `floor(0.5 + n/640)` - + * 320/640 is 0.5, so adding it before truncating is the standard round-to-nearest trick. + */ + fun maxHit(rangedLevel: Int, ammoRangedStrength: Int): Int { + val effectiveRanged = rangedLevel + POWERSHOT_LEVEL_BONUS + return (effectiveRanged * (ammoRangedStrength + RANGED_STRENGTH_BASE) + ROUNDING) / DIVISOR + } + + fun resolveDamage(maxHit: Int, isMaxHit: Boolean, rollInclusive: (IntRange) -> Int): Int = + if (isMaxHit) maxHit else rollInclusive(0..maxHit) + + const val POWERSHOT_LEVEL_BONUS = 10 + const val RANGED_STRENGTH_BASE = 64 + const val ROUNDING = 320 + const val DIVISOR = 640 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicShortbowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicShortbowSpecialAttack.kt new file mode 100644 index 000000000..024e0c016 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicShortbowSpecialAttack.kt @@ -0,0 +1,262 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.proj.ProjAnim +import org.rsmod.game.type.getInvObj +import org.rsmod.game.type.getOrNull + +/** + * Snapshot fires two independent arrows. Its hit rolls use the cache-backed projectile flow, but + * retain the unusual Old School max-hit calculation: visible Ranged level plus ten and the + * ammunition's ranged-strength value only. Gear strength, Void, prayers, Slayer, and Salve do + * not enter the damage calculation; the normal ranged accuracy calculation still does. + * + * Visuals, taken from the cache: + * - `seq.snapshot` (1074) is the spec animation - two identical draw-and-release cycles back to + * back, 27 client cycles each, a sped-up double of the normal `human_bow` (426) draw. The + * second arrow looses 27 client cycles after the first, within the same server tick. + * - `spotanim.sp_attack_snapshot_spotanim` (256) is Snapshot's launch glow, one per draw. + * - `spotanim.sp_attack_glow_arrow_travel` (249) overrides the ammo's own `proj_travel` for both + * shots - the in-flight arrow itself needs to glow, not just the draw. + * - Sound: `SNAPSHOT_SOUND` = synth 2545, unaliased in this cache. + */ +class MagicShortbowSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val worldRepo: WorldRepository, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val snapshot = Snapshot(manager, ammunition, worldRepo) + registerRanged("obj.magic_shortbow", snapshot) + registerRanged("obj.magic_shortbow_i", snapshot) + registerRanged("obj.br_magic_bow", snapshot) + } + + private class Snapshot( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val worldRepo: WorldRepository, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = snapshot(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = snapshot(target, attack) + + private fun ProtectedAccess.snapshot( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val quiverType = getOrNull(player.quiver) + if (!ammunition.attemptAmmoUsage(player, weaponType, quiverType)) { + manager.stopCombat(this) + return false + } + + if ((player.quiver?.count ?: 0) < ARROWS_PER_SNAPSHOT) { + manager.stopCombat(this) + mes("You need to have at least 2 arrows in your quiver for this special attack.") + return false + } + + val travelSpotanim = quiverType?.paramOrNull(params.proj_travel) + val projectileType = weaponType.paramOrNull(params.proj_type) + if (travelSpotanim == null || projectileType == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + + anim(SNAPSHOT_SEQUENCE) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + soundSynth(SNAPSHOT_SOUND) + + // One launch glow per draw, on the shooter. The second is scheduled for the second + // draw via the spotanim's own client-cycle delay, in a different slot so it doesn't + // overwrite the first before it has played. This replaces the ammo's generic + // proj_launch graphic entirely - sending both into the same slot lets the ammo's + // plain launch overwrite the spec glow, which is why an earlier version showed + // rune-arrow-coloured launches instead of the green glow. + spotanim(SNAPSHOT_GLOW_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) + spotanim( + SNAPSHOT_GLOW_SPOTANIM, + delay = SECOND_ARROW_OFFSET_CYCLES, + height = 96, + slot = SECOND_GLOW_SPOTANIM_SLOT, + ) + + // Live testing confirmed the in-flight arrow needs to glow too (not just the draw) - + // `sp_attack_glow_arrow_travel` (249), sitting right next to the launch glow (250) in + // the same spec-graphic id block. Overrides the ammo's own plain proj_travel entirely + // rather than layering on top of it. + val travelSpot = SNAPSHOT_TRAVEL_GLOW_SPOTANIM + val projanim = RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id) + val travelSpotId = travelSpot.asRSCM(RSCMType.SPOTANIM) + // `manager.spawnProjectile` dispatches immediately using the ammo's generic + // `projanim.arrow` delay (41 client cycles) - tuned for a normal single-draw bow shot, + // not this animation. `seq.snapshot`'s own draw-and-release cycle is only 27 cycles, so + // that default fires the arrow 14 cycles into the *second* draw instead of during the + // first. Building the ProjAnim by hand instead lets us launch it at cycle 16 (where the + // held-draw frames end and the snap/release frames begin) without dispatching the + // wrongly-timed default first. Shifting both startTime and endTime by the same amount + // keeps the real flight duration (baked additively into endTime) unchanged - only when + // it starts moving changes. + val defaultProjectile = + when (target) { + is Npc -> ProjAnim.fromBoundsToNpc(player.bounds(), target, travelSpotId, projanim) + is Player -> + ProjAnim.fromBoundsToPlayer(player.bounds(), target, travelSpotId, projanim) + } + val launchDelta = defaultProjectile.startTime - FIRST_ARROW_RELEASE_CYCLE + val firstProjectile = + defaultProjectile.copy( + startTime = FIRST_ARROW_RELEASE_CYCLE, + endTime = defaultProjectile.endTime - launchDelta, + ) + worldRepo.projAnim(firstProjectile) + // Same arrow, same speed and arc, launched 27 client cycles later to match the second + // draw in the animation. Two projectiles with byte-identical timing dispatched in the + // same tick render as one, so the offset is what makes the second arrow visible - it + // is not a server-side delay() (a whole tick is far too slow) and it is not a + // different projanim (Dark bow's doublearrow_one/two pair has different angle and + // stepMultiplier values in projectiles.toml, an intentional high/low-arc effect that + // is wrong here). + val secondProjectile = + firstProjectile.copy( + startTime = firstProjectile.startTime + SECOND_ARROW_OFFSET_CYCLES, + endTime = firstProjectile.endTime + SECOND_ARROW_OFFSET_CYCLES, + ) + worldRepo.projAnim(secondProjectile) + + val firstDamage = snapshotDamage(target, attack, quiverType) + val secondDamage = snapshotDamage(target, attack, quiverType) + + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = firstProjectile.serverCycles, + ) + manager.queueRangedHit( + source = this, + target = target, + ammo = quiverType, + damage = firstDamage, + clientDelay = firstProjectile.clientCycles, + hitDelay = firstProjectile.serverCycles, + ) + + // Both hits register on the first arrow's tick, not the second arrow's own (slightly + // later, visual-only) serverCycles - the 27-client-cycle offset is purely for the + // second arrow to render as a separate projectile, not a real gameplay delay between + // the two hits. + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = firstProjectile.serverCycles, + ) + manager.queueRangedDamage( + source = this, + target = target, + ammo = quiverType, + damage = secondDamage, + hitDelay = firstProjectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, firstDamage + secondDamage) + + if (player.quiver?.count == 1) { + mes("You now have only 1 arrow left in your quiver.") + } + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.snapshotDamage( + target: PathingEntity, + attack: CombatAttack.Ranged, + ammo: ItemServerType, + ): Int { + val successful = + manager.rollRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = SNAPSHOT_ACCURACY_MULTIPLIER, + ) + if (!successful) { + return 0 + } + + val maxHit = snapshotMaxHit(ammo) + return if (player.adminMaxHit) maxHit else random.of(0..maxHit) + } + + private fun ProtectedAccess.snapshotMaxHit(ammo: ItemServerType): Int { + val effectiveRanged = stat("stat.ranged") + SNAPSHOT_LEVEL_BONUS + val rangedStrength = ammo.param(params.ranged_strength) + return (effectiveRanged * (rangedStrength + RANGED_STRENGTH_BASE) + ROUNDING) / DIVISOR + } + } + + private companion object { + const val SNAPSHOT_SEQUENCE = "seq.snapshot" + const val SNAPSHOT_GLOW_SPOTANIM = "spotanim.sp_attack_snapshot_spotanim" + const val SNAPSHOT_TRAVEL_GLOW_SPOTANIM = "spotanim.sp_attack_glow_arrow_travel" + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SNAPSHOT_SOUND = 2545 + + /** Any slot other than [constants.spotanim_slot_combat], so the two glows coexist. */ + const val SECOND_GLOW_SPOTANIM_SLOT = 0 + + /** + * Client cycles between the two draws in `seq.snapshot`: one full draw-and-release cycle + * (frame delays 2+2+2+2+8+5+2+2+2+2). 30 client cycles = 1 server tick. + */ + const val SECOND_ARROW_OFFSET_CYCLES = 27 + + /** + * Client cycle within the first draw where the arrow actually leaves the bow: the pulled- + * back hold ends and the snap/release frames begin (2+2+2+2+8 into `seq.snapshot`'s frame + * delays). Live-tested as too late at the ammo's generic `projanim.arrow` delay of 41, + * which fires the arrow into the *second* draw instead of the first - needs your eyes to + * confirm/tune this exact value. + */ + const val FIRST_ARROW_RELEASE_CYCLE = 16 + + const val ARROWS_PER_SNAPSHOT = 2 + const val SNAPSHOT_LEVEL_BONUS = 10 + const val RANGED_STRENGTH_BASE = 64 + const val ROUNDING = 320 + const val DIVISOR = 640 + const val SNAPSHOT_ACCURACY_MULTIPLIER = 10.0 / 7.0 + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinSpecialAttack.kt new file mode 100644 index 000000000..b61e3a9d1 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinSpecialAttack.kt @@ -0,0 +1,264 @@ +package org.rsmod.content.other.special.attacks.ranged + +import jakarta.inject.Inject +import jakarta.inject.Singleton +import java.util.IdentityHashMap +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.done.hitmark_groups +import org.rsmod.api.player.hit.queueImpactHit +import org.rsmod.api.player.isValidTarget +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.queue.WorldQueueList +import org.rsmod.game.type.getInvObj + +/** + * Phantom Strike makes a normal Ranged hit and, against players, makes the target bleed 75% of + * the final initial-hit damage in ten-damage Ranged ticks. + * + * Deadman and Bounty Rusher cache variants have different ammunition and accuracy rules, but use + * the same visual assets and player-only bleed effect. + */ +class MorrigansJavelinSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val bleed: MorrigansJavelinBleed, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val deadman = + PhantomStrike( + manager = manager, + ammunition = ammunition, + bleed = bleed, + accuracyMultiplier = 1.0, + consumesThrownAmmo = true, + ) + registerRanged("obj.morrigans_javelin", deadman) + registerRanged("obj.br_morrigans_javelin", deadman) + + registerRanged( + "obj.morrigans_javelin_bh", + PhantomStrike( + manager = manager, + ammunition = ammunition, + bleed = bleed, + accuracyMultiplier = 1.5, + consumesThrownAmmo = false, + ), + ) + } + + private class PhantomStrike( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val bleed: MorrigansJavelinBleed, + private val accuracyMultiplier: Double, + private val consumesThrownAmmo: Boolean, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = phantomStrike(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = phantomStrike(target, attack) + + private fun ProtectedAccess.phantomStrike( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + if (!ammunition.attemptAmmoUsage(player, weaponType, ammo = null)) { + manager.stopCombat(this) + return false + } + + anim(MORRIGANS_JAVELIN_SEQUENCE) + spotanim( + spot = MORRIGANS_JAVELIN_LAUNCH_SPOTANIM, + height = 0, + slot = constants.spotanim_slot_combat, + ) + val projectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = MORRIGANS_JAVELIN_TRAVEL_SPOTANIM, + projanim = THROWN_PROJANIM, + ) + val damage = + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = accuracyMultiplier, + ) + + if (consumesThrownAmmo) { + ammunition.useThrownWeapon( + player = player, + weaponType = weaponType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + } + + val source = player + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = null, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value to seed the bleed from - no impact callback needed. + if (target is Player && damage > 0) { + bleed.apply(source, target, damage) + } + + if (consumesThrownAmmo && player.righthand == null) { + mes("That was your last one!") + } else { + manager.continueCombat(this, target) + } + return true + } + } + + private companion object { + const val MORRIGANS_JAVELIN_SEQUENCE = "seq.weapon_morrigans_javelin_special01" + const val MORRIGANS_JAVELIN_LAUNCH_SPOTANIM = "spotanim.morrigans_javelin_spotanim" + const val MORRIGANS_JAVELIN_TRAVEL_SPOTANIM = "spotanim.morrigans_javelin_projanim" + const val THROWN_PROJANIM = "projanim.thrown" + } +} + +/** + * Owns all active Morrigan javelin bleed stacks. Each stack is independent, which preserves + * successive successful javelin specials rather than replacing a prior bleed. + * + * Damage is deliberately queued as [HitType.Ranged] through [Player.queueImpactHit]. This means + * each tick is modified at impact time by normal player hit rules, including Ranged protection + * prayers, while retaining ordinary combat attribution and death processing. + */ +@Singleton +class MorrigansJavelinBleed +@Inject +constructor(private val worldQueues: WorldQueueList) { + private val active: IdentityHashMap> = IdentityHashMap() + + fun apply( + source: Player, + target: Player, + initialFinalDamage: Int, + ): Boolean { + val remainingDamage = MorrigansJavelinBleedDamage.totalDamage(initialFinalDamage) + if (remainingDamage <= 0 || !target.isValidTarget()) { + return false + } + + val stacks = active.getOrPut(target) { mutableListOf() } + val hadActiveBleed = stacks.isNotEmpty() + val stack = + BleedStack( + source = source, + target = target, + targetUid = target.uid.packed, + remainingDamage = remainingDamage, + ) + stacks += stack + target.mes(if (hadActiveBleed) CONTINUE_BLEED_MESSAGE else START_BLEED_MESSAGE) + arm(stack) + return true + } + + private fun arm(stack: BleedStack) { + worldQueues.add(TICK_INTERVAL) { + tick(stack) + } + } + + private fun tick(stack: BleedStack) { + if (!isActive(stack) || !stack.targetIsCurrent()) { + remove(stack) + return + } + + val damage = minOf(DAMAGE_PER_TICK, stack.remainingDamage) + stack.remainingDamage -= damage + stack.target.queueImpactHit( + source = stack.source, + delay = TARGET_HIT_DELAY, + type = HitType.Ranged, + damage = damage, + hitmark = hitmark_groups.bleed, + ) + + if (stack.remainingDamage <= 0) { + remove(stack) + } else { + arm(stack) + } + } + + private fun isActive(stack: BleedStack): Boolean = active[stack.target]?.contains(stack) == true + + private fun remove(stack: BleedStack) { + val stacks = active[stack.target] ?: return + stacks.remove(stack) + if (stacks.isEmpty()) { + active.remove(stack.target) + } + } + + private fun BleedStack.targetIsCurrent(): Boolean = + target.isValidTarget() && target.uid.packed == targetUid + + private class BleedStack( + val source: Player, + val target: Player, + val targetUid: Int, + var remainingDamage: Int, + ) + + private companion object { + const val DAMAGE_PER_TICK = 10 + const val TICK_INTERVAL = 1 + const val TARGET_HIT_DELAY = 1 + const val START_BLEED_MESSAGE = "You start to bleed as a result of the javelin strike." + const val CONTINUE_BLEED_MESSAGE = "You continue to bleed as a result of the javelin strike." + } +} + +internal object MorrigansJavelinBleedDamage { + fun totalDamage(initialFinalDamage: Int): Int = + (initialFinalDamage.coerceAtLeast(0).toLong() * 75L / 100L).toInt() + + fun ticks(initialFinalDamage: Int): List { + var remaining = totalDamage(initialFinalDamage) + val ticks = mutableListOf() + while (remaining > 0) { + val damage = minOf(10, remaining) + ticks += damage + remaining -= damage + } + return ticks + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeSpecialAttack.kt new file mode 100644 index 000000000..c42bbe9ce --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeSpecialAttack.kt @@ -0,0 +1,218 @@ +package org.rsmod.content.other.special.attacks.ranged + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.styles.RangedAttackStyle +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.hit.modifier.MorriganHamstring +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** + * Hamstring differs by cache variant: + * + * - Deadman Morrigan's throwing axe: 20-120% damage and a one-tick faster next attack. + * - Bounty Hunter Morrigan's throwing axe: 50-150% damage with 150% accuracy. + * + * A landed hit on a player makes their running energy drain six times faster for one minute. + */ +class MorrigansThrowingAxeSpecialAttack +@Inject +constructor(private val ammunition: RangedAmmoManager) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerRanged( + "obj.morrigans_thrownaxe", + Hamstring( + manager = manager, + ammunition = ammunition, + accuracyMultiplier = 1.0, + minimumDamagePercent = 20, + maximumDamagePercent = 120, + speedUpNextAttack = true, + consumesThrownAmmo = true, + ), + ) + registerRanged( + "obj.morrigans_thrownaxe_bh", + Hamstring( + manager = manager, + ammunition = ammunition, + accuracyMultiplier = 1.5, + minimumDamagePercent = 50, + maximumDamagePercent = 150, + speedUpNextAttack = false, + consumesThrownAmmo = false, + ), + ) + } + + private class Hamstring( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val accuracyMultiplier: Double, + private val minimumDamagePercent: Int, + private val maximumDamagePercent: Int, + private val speedUpNextAttack: Boolean, + private val consumesThrownAmmo: Boolean, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = hamstring(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = hamstring(target, attack) + + private fun ProtectedAccess.hamstring( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + if (!ammunition.attemptAmmoUsage(player, weaponType, ammo = null)) { + manager.stopCombat(this) + return false + } + + anim(MORRIGANS_THROWING_AXE_SEQUENCE) + soundSynth(HAMSTRING_SOUND) + spotanim( + spot = MORRIGANS_THROWING_AXE_LAUNCH_SPOTANIM, + height = 0, + slot = constants.spotanim_slot_combat, + ) + val projectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = MORRIGANS_THROWING_AXE_TRAVEL_SPOTANIM, + projanim = THROWN_PROJANIM, + ) + + val successful = + manager.rollRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = accuracyMultiplier, + ) + val damage = + if (successful) { + val normalMax = + manager.calculateRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + boltSpecDamage = 0, + ) + val range = + MorrigansThrowingAxeDamage.range( + normalMax = normalMax, + minimumPercent = minimumDamagePercent, + maximumPercent = maximumDamagePercent, + ) + if (range.first == range.last) range.first else random.of(range) + } else { + 0 + } + + // The Deadman weapon's March 2025 change applies to a successful accuracy roll, not + // merely a positive post-mitigation hit. This must happen before combat is continued. + if (successful && speedUpNextAttack) { + manager.setNextAttackDelay(this, weaponType.hamstringNextAttackDelay(attack)) + } + + if (consumesThrownAmmo) { + ammunition.useThrownWeapon( + player = player, + weaponType = weaponType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = null, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // The accuracy roll is already known synchronously (real OSRS doesn't clamp damage + // after the roll), so this doesn't need to wait for the hit to actually land - no + // impact callback needed. + if (successful && target is Player) { + target.applyMorrigansHamstring() + } + + if (consumesThrownAmmo && player.righthand == null) { + mes("That was your last one!") + } else { + manager.continueCombat(this, target) + } + return true + } + } + + private companion object { + const val MORRIGANS_THROWING_AXE_SEQUENCE = "seq.weapon_morrigans_throwingaxe_special01" + const val MORRIGANS_THROWING_AXE_LAUNCH_SPOTANIM = "spotanim.morrigans_taxe_spotanim" + const val MORRIGANS_THROWING_AXE_TRAVEL_SPOTANIM = "spotanim.morrigans_taxe_projanim" + const val THROWN_PROJANIM = "projanim.thrown" + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val HAMSTRING_SOUND = 2706 + } +} + +internal object MorrigansThrowingAxeDamage { + fun range( + normalMax: Int, + minimumPercent: Int, + maximumPercent: Int, + ): IntRange { + require(minimumPercent in 0..maximumPercent) + if (normalMax <= 0) { + return 0..0 + } + val minimum = (normalMax.toLong() * minimumPercent / 100L).toInt() + val maximum = (normalMax.toLong() * maximumPercent / 100L).toInt() + return minimum..maximum.coerceAtLeast(minimum) + } +} + +private fun dev.openrune.types.ItemServerType.hamstringNextAttackDelay( + attack: CombatAttack.Ranged, +): Int { + val baseAttackRate = param(params.attackrate).coerceAtLeast(1) + val actualAttackRate = + if (attack.style == RangedAttackStyle.Rapid) { + (baseAttackRate - 1).coerceAtLeast(1) + } else { + baseAttackRate + } + return (actualAttackRate - 1).coerceAtLeast(1) +} + +private fun Player.applyMorrigansHamstring() { + MorriganHamstring.activate(this) + mes("You've been hamstrung! For the next minute, your run energy will drain 6x faster.") +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RangedSpecialAttackEffects.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RangedSpecialAttackEffects.kt new file mode 100644 index 000000000..d5d047fcb --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RangedSpecialAttackEffects.kt @@ -0,0 +1,15 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess + +/** Plays the equipped ranged weapon's cache-defined normal attack animation and sound. */ +internal fun ProtectedAccess.playRangedWeaponFx(weapon: ItemServerType): Boolean { + val attackAnim = weapon.paramOrNull(params.attack_anim_stance1) ?: return false + anim(RSCM.getReverseMapping(RSCMType.SEQ, attackAnim.id)) + weapon.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + return true +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RosewoodBlowpipeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RosewoodBlowpipeSpecialAttack.kt new file mode 100644 index 000000000..59f86f342 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RosewoodBlowpipeSpecialAttack.kt @@ -0,0 +1,145 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.ranged.RangedAmmunition +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.ranged.BlowpipeAmmo +import org.rsmod.api.player.righthand +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** Rapid Burst fires two independently rolled stored darts at reduced accuracy and increased damage. */ +class RosewoodBlowpipeSpecialAttack @Inject constructor(private val random: GameRandom) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerRanged("obj.rosewood_blowpipe", RapidBurst(manager, random)) + } + + private class RapidBurst( + private val manager: SpecialAttackManager, + private val random: GameRandom, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = rapidBurst(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = rapidBurst(target, attack) + + private fun ProtectedAccess.rapidBurst( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val equipped = player.righthand + val dart = BlowpipeAmmo.loadedDart(equipped) + if (dart == null || !BlowpipeAmmo.canUseLoadedDart(equipped)) { + manager.stopCombat(this) + mes("Your rosewood blowpipe has no usable darts loaded.") + return false + } + if (!BlowpipeAmmo.hasDarts(equipped, DARTS_PER_SPECIAL)) { + manager.stopCombat(this) + mes("You need at least 2 darts loaded to use this special attack.") + return false + } + + val consumedDarts = + (if (RangedAmmunition.conserveAmmo(player, random)) 0 else 1) + + (if (RangedAmmunition.conserveAmmo(player, random)) 0 else 1) + + anim(RAPID_BURST_SEQUENCE) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + + val projanim = weaponType.projectileType() + val firstProjectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = RAPID_BURST_TRAVEL_SPOTANIM, + projanim = projanim, + ) + val secondProjectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = RAPID_BURST_TRAVEL_SPOTANIM, + projanim = projanim, + ) + val firstDamage = rapidBurstDamage(target, attack) + val secondDamage = rapidBurstDamage(target, attack) + + val consumption = BlowpipeAmmo.consume(player, darts = consumedDarts) + if (consumption == null) { + manager.stopCombat(this) + return false + } + + manager.giveCombatXp(this, target, attack, firstDamage + secondDamage) + manager.queueRangedHit( + source = this, + target = target, + ammo = dart.type, + damage = firstDamage, + clientDelay = firstProjectile.clientCycles, + hitDelay = firstProjectile.serverCycles, + ) + manager.queueRangedDamage( + source = this, + target = target, + ammo = dart.type, + damage = secondDamage, + hitDelay = secondProjectile.serverCycles, + ) + + if (consumption.becameEmpty) { + mes("Your rosewood blowpipe has run out of darts.") + manager.stopCombat(this) + } else { + manager.continueCombat(this, target) + } + return true + } + + private fun ProtectedAccess.rapidBurstDamage( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Int = + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = RAPID_BURST_ACCURACY_MULTIPLIER, + maxHitMultiplier = RAPID_BURST_MAX_HIT_MULTIPLIER, + ) + + private fun ItemServerType.projectileType(): String = + paramOrNull(params.proj_type)?.let { + RSCM.getReverseMapping(RSCMType.PROJANIM, it.id) + } ?: THROWN_PROJANIM + } + + private companion object { + const val DARTS_PER_SPECIAL: Int = 2 + const val RAPID_BURST_ACCURACY_MULTIPLIER: Double = 0.8 + const val RAPID_BURST_MAX_HIT_MULTIPLIER: Double = 1.1 + const val RAPID_BURST_SEQUENCE: String = "seq.rosewood_blowpipe_special_attack" + const val RAPID_BURST_TRAVEL_SPOTANIM: String = "spotanim.rosewood_blowpipe_special_travel" + const val THROWN_PROJANIM: String = "projanim.thrown" + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RuneThrownaxeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RuneThrownaxeSpecialAttack.kt new file mode 100644 index 000000000..ae8c2f792 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RuneThrownaxeSpecialAttack.kt @@ -0,0 +1,360 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.ServerCacheManager +import dev.openrune.rscm.RSCM.asRSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.aconverted.SpotanimType +import dev.openrune.types.varp.baseVar +import dev.openrune.types.varp.bits +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.combat.player.PvPAreaAttackManager +import org.rsmod.api.config.constants +import org.rsmod.api.death.NpcAttackValidateHook +import org.rsmod.api.death.NpcAttackValidateResult +import org.rsmod.api.npc.isValidTarget +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.repo.player.PlayerRepository +import org.rsmod.api.repo.world.WorldRepository +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.interact.InteractionOp +import org.rsmod.game.type.getInvObj +import org.rsmod.game.vars.VarPlayerIntMap +import org.rsmod.map.zone.ZoneKey +import org.rsmod.utils.bits.withBits + +/** + * Chainhit makes one rune thrownaxe ricochet from each landed target to the next eligible + * opponent. Each hop is resolved only after the previous projectile has hit, so failed accuracy + * rolls terminate the sequence instead of becoming a simultaneous area hit. + */ +class RuneThrownaxeSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val chainTargets: RuneThrownaxeChainTargets, + private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerRanged("obj.rune_thrownaxe", Chainhit(manager, ammunition, chainTargets, pvp, worldRepo)) + } + + private class Chainhit( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val chainTargets: RuneThrownaxeChainTargets, + private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = chainhit(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = chainhit(target, attack) + + private fun ProtectedAccess.chainhit( + primary: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + if (!ammunition.attemptAmmoUsage(player, weaponType, ammo = null)) { + manager.stopCombat(this) + return false + } + + anim(CHAINHIT_SEQUENCE) + soundSynth(THROWNAXE_SOUND) + spotanim( + spot = CHAINHIT_LAUNCH_SPOTANIM, + height = 96, + slot = constants.spotanim_slot_combat, + ) + + val projectile = + manager.spawnProjectile( + source = this, + target = primary, + spotanim = CHAINHIT_TRAVEL_SPOTANIM, + projanim = THROWN_PROJANIM, + ) + val roll = rollChainhit(primary, attack) + val chain = ChainState(source = this, attack = attack, primary = primary) + + // Chainhit throws one axe, regardless of how many targets it subsequently reaches. + ammunition.useThrownWeapon( + player = player, + weaponType = weaponType, + dropCoord = primary.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, primary, attack, roll.damage) + manager.queueRangedHit( + source = this, + target = primary, + ammo = null, + damage = roll.damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // The accuracy roll is already known synchronously (real OSRS doesn't clamp damage + // after the roll), so a failed hit simply never starts the chain - no impact callback + // needed to gate it. + if (roll.successful) { + chain.continueFrom(primary) + } + + if (player.righthand == null) { + mes("That was your last one!") + } else { + manager.continueCombat(this, primary) + } + return true + } + + /** + * The damage side of Chainhit deliberately omits only the attacker's offensive Ranged + * prayers. The normal max-hit path still retains equipment, style, Void, target-specific, + * and final-hit protection handling. + */ + private fun ProtectedAccess.rollChainhit( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): ChainhitRoll { + val successful = + manager.rollRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + if (!successful) { + return ChainhitRoll(successful = false, damage = 0) + } + + val damage = + player.withOffensiveRangedPrayersMasked { + manager.rollRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + boltSpecDamage = 0, + ) + } + return ChainhitRoll(successful = true, damage = damage) + } + + /** + * Holds only this short-lived ricochet sequence. The primary energy has already been + * deducted when the special returns; every additional target pays a further 10% immediately + * as the axe leaves for that target. + */ + private inner class ChainState( + private val source: ProtectedAccess, + private val attack: CombatAttack.Ranged, + primary: PathingEntity, + ) { + private val sourcePlayer: Player = source.player + private val sourceUid: Int = sourcePlayer.uid.packed + private val hitTargets: MutableList = mutableListOf(primary) + private var hitCount: Int = 1 + + fun continueFrom(previous: PathingEntity) { + if ( + hitCount >= MAX_CHAIN_TARGETS || + !sourcePlayer.isSlotAssigned || + sourcePlayer.uid.packed != sourceUid || + !source.mapMultiway() || + !manager.hasSpecialEnergy(source, ENERGY_PER_TARGET) + ) { + return + } + + val next = chainTargets.next(source, previous, hitTargets) ?: return + val roll = source.rollChainhit(next, attack) + + // Additional Chainhit energy is paid for the attempted extra target. A failed + // accuracy roll still consumes that target's hop and ends this sequence. + manager.takeSpecialEnergy(source, ENERGY_PER_TARGET) + hitTargets += next + hitCount++ + + val projectile = worldRepo.spawnChainhitProjectile(previous, next) + manager.giveCombatXp(source, next, attack, roll.damage) + manager.queueRangedHit( + source = source, + target = next, + ammo = null, + damage = roll.damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + if (next is Player) { + pvp.applySecondarySpecialAttack(source, next) + } + // The accuracy roll is already known synchronously (real OSRS doesn't clamp + // damage after the roll), so a failed hit simply never continues the chain - no + // impact callback needed to gate it. + if (roll.successful) { + continueFrom(next) + } + } + } + } + + private companion object { + const val CHAINHIT_SEQUENCE = "seq.chainhit" + const val CHAINHIT_LAUNCH_SPOTANIM = "spotanim.sp_attack_chainhit_launch_spotanim" + const val CHAINHIT_TRAVEL_SPOTANIM = "spotanim.sp_attack_chainhit_travel_spotanim" + const val THROWN_PROJANIM = "projanim.thrown" + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val THROWNAXE_SOUND = 2528 + const val ENERGY_PER_TARGET = 100 + const val MAX_CHAIN_TARGETS = 5 + } +} + +/** + * Finds the next valid Chainhit opponent around the most recently struck entity. It intentionally + * re-evaluates targets at each impact because targets can move or die while an earlier projectile + * is in flight. + */ +class RuneThrownaxeChainTargets +@Inject +constructor( + private val npcs: NpcRepository, + private val players: PlayerRepository, + private val npcAttackValidateHooks: Set, + private val pvp: PvPAreaAttackManager, +) { + fun next( + source: ProtectedAccess, + previous: PathingEntity, + alreadyHit: Collection, + ): PathingEntity? { + val zone = ZoneKey.from(previous.coords) + val candidates = mutableListOf() + + for (npc in npcs.findAll(zone, zoneRadius = SEARCH_ZONE_RADIUS)) { + if ( + npc.isAlreadyHit(alreadyHit) || + !npc.isWithinDistance(previous, CHAIN_RADIUS) || + !canAttack(source.player, npc) + ) { + continue + } + candidates += npc + } + + for (player in players.findAll(zone, zoneRadius = SEARCH_ZONE_RADIUS)) { + if ( + player === source.player || + player.isAlreadyHit(alreadyHit) || + !player.isWithinDistance(previous, CHAIN_RADIUS) || + !pvp.canAttack(source.player, player) + ) { + continue + } + candidates += player + } + return source.random.pickOrNull(candidates) + } + + private fun canAttack(source: Player, target: Npc): Boolean { + if (!target.isValidTarget() || !target.visType.hasOp(InteractionOp.Op2.slot)) { + return false + } + return npcAttackValidateHooks.all { hook -> + hook.validate(source, target) !is NpcAttackValidateResult.Deny + } + } + + private fun PathingEntity.isAlreadyHit(targets: Collection): Boolean = + targets.any { it === this } + + private companion object { + const val CHAIN_RADIUS = 3 + const val SEARCH_ZONE_RADIUS = 1 + } +} + +private data class ChainhitRoll(val successful: Boolean, val damage: Int) + +private fun WorldRepository.spawnChainhitProjectile( + source: PathingEntity, + target: PathingEntity, +) = + when (source) { + is Npc -> + when (target) { + is Npc -> projAnim(source, target, CHAINHIT_SPOTANIM, CHAINHIT_PROJANIM) + is Player -> projAnim(source, target, CHAINHIT_SPOTANIM, CHAINHIT_PROJANIM) + } + + is Player -> + when (target) { + is Npc -> projAnim(source, target, CHAINHIT_SPOTANIM, CHAINHIT_PROJANIM) + is Player -> projAnim(source, target, CHAINHIT_SPOTANIM, CHAINHIT_PROJANIM) + } + } + +private inline fun Player.withOffensiveRangedPrayersMasked(block: () -> T): T { + val saved = + IntArray(OFFENSIVE_RANGED_PRAYER_VARBITS.size) { index -> + vars[OFFENSIVE_RANGED_PRAYER_VARBITS[index]] + } + for (varbit in OFFENSIVE_RANGED_PRAYER_VARBITS) { + setRawVarbit(varbit, 0) + } + return try { + block() + } finally { + for (index in OFFENSIVE_RANGED_PRAYER_VARBITS.indices) { + setRawVarbit(OFFENSIVE_RANGED_PRAYER_VARBITS[index], saved[index]) + } + } +} + +/** + * Temporarily changes the model-only varbit map. This intentionally bypasses the player variable + * delegate: the values are restored synchronously around a pure combat calculation and must never + * be transmitted to the client or marked for persistence. + */ +private fun Player.setRawVarbit(internal: String, value: Int) { + val varbit = + checkNotNull(ServerCacheManager.getVarbit(internal.asRSCM(RSCMType.VARBIT))) { + "Missing required varbit: $internal" + } + VarPlayerIntMap.assertVarBitBounds(varbit, value) + val mappedValue = vars[varbit.baseVar] + vars.backing[varbit.baseVar.id] = mappedValue.withBits(varbit.bits, value) +} + +private val CHAINHIT_SPOTANIM: SpotanimType = + SpotanimType("spotanim.sp_attack_chainhit_travel_spotanim".asRSCM(RSCMType.SPOTANIM)) +private const val CHAINHIT_PROJANIM = "projanim.thrown" +private val OFFENSIVE_RANGED_PRAYER_VARBITS = + arrayOf( + "varbit.prayer_sharpeye", + "varbit.prayer_hawkeye", + "varbit.prayer_eagleeye", + "varbit.prayer_rigour", + ) diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/SeercullSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/SeercullSpecialAttack.kt new file mode 100644 index 000000000..308de7b0c --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/SeercullSpecialAttack.kt @@ -0,0 +1,165 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.player.stat.baseMagicLvl +import org.rsmod.api.player.stat.magicLvl +import org.rsmod.api.player.stat.rangedLvl +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj +import org.rsmod.game.type.getOrNull + +/** + * Soulshot always lands, but its damage uses only the visible Ranged level and arrow Ranged + * Strength. Equipment strength, attack style, prayers, Void, Salve, and Slayer bonuses do not + * contribute to this special's max hit. + */ +class SeercullSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val random: GameRandom, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + // Seercull (cache ID 6724) is absent from the rev-240 obj gameval table. + registerRanged(SEERCULL_ID, SOULSHOT_ENERGY, Soulshot(manager, ammunition, random)) + } + + private class Soulshot( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + private val random: GameRandom, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = soulshot(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = soulshot(target, attack) + + private fun ProtectedAccess.soulshot( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val arrowType = getOrNull(player.quiver) + if (!ammunition.attemptAmmoUsage(player, weaponType, arrowType)) { + manager.stopCombat(this) + return false + } + + val travelSpotanim = arrowType?.paramOrNull(params.proj_travel) + val projectileType = weaponType.paramOrNull(params.proj_type) + if (travelSpotanim == null || projectileType == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + if (!playRangedWeaponFx(weaponType)) { + manager.stopCombat(this) + mes("The bow fails to fire.") + return false + } + + // Soulshot's own dedicated effects, found via live in-game id testing (external wiki/ + // RuneLite search found nothing - 472 turned out to be this project's own custom + // addition, not from the real game). Overrides the ammo's plain proj_launch/proj_travel + // entirely, same pattern as every other special in this file. Launch glow is this + // project's own custom white variant of the shared "sp_attack_glow_arrow_launch" family + // (Powershot's plain one is 250); travel and hit are the real, official Dagannoth + // Supreme arrow-shower graphics, reused here since Seercull drops from it. + soundSynth(SOULSHOT_SOUND) + spotanim(SOULSHOT_LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) + + val projectile = + manager.spawnProjectile( + this, + target, + SOULSHOT_TRAVEL_SPOTANIM, + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), + ) + target.spotanim(SOULSHOT_HIT_SPOTANIM, height = 0, delay = projectile.clientCycles) + + // Do not use rollRangedDamage/max-hit: those include attack style, prayers, Void, + // worn strength, and conditional bonuses that Soulshot deliberately ignores. + val damage = random.of(0..SoulshotDamage.maxHit(player.rangedLvl, arrowType)) + + ammunition.useQuiverAmmo( + player = player, + quiverType = arrowType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = arrowType, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value to gate the Magic drain on - no impact callback needed. + if (damage > 0) { + drainSoulshotMagic(target, damage) + } + manager.continueCombat(this, target) + return true + } + } + + private companion object { + const val SEERCULL_ID = 6724 + const val SOULSHOT_ENERGY = 1000 + const val SOULSHOT_LAUNCH_SPOTANIM = "spotanim.sp_attack_glow_arrow_launch_white" + const val SOULSHOT_TRAVEL_SPOTANIM = "spotanim.dagannoth_arrow_spotanim_travel" + const val SOULSHOT_HIT_SPOTANIM = "spotanim.dagannoth_arrow_spotanim_hit" + + /** Unaliased in this cache's gamevals - no `synth.` name exists for it. */ + const val SOULSHOT_SOUND = 2546 + } +} + +/** Soulshot's cache-independent max-hit formula: floor(0.5 + ((Ranged + 10) * (Ammo + 64)) / 640). */ +internal object SoulshotDamage { + fun maxHit(visibleRangedLevel: Int, arrow: ItemServerType): Int = + maxHit(visibleRangedLevel, arrow.param(params.ranged_strength)) + + fun maxHit(visibleRangedLevel: Int, arrowRangedStrength: Int): Int = + ((visibleRangedLevel + 10) * (arrowRangedStrength + 64) + 320) / 640 +} + +private fun drainSoulshotMagic(target: PathingEntity, damage: Int) { + when (target) { + is Player -> { + if (target.magicLvl >= target.baseMagicLvl) { + target.statSub("stat.magic", constant = damage, percent = 0) + } + } + is Npc -> { + if (target.magicLvl >= target.baseMagicLvl) { + target.magicLvl = (target.magicLvl - damage).coerceAtLeast(0) + } + } + } +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialAttack.kt new file mode 100644 index 000000000..3867662f3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialAttack.kt @@ -0,0 +1,214 @@ +package org.rsmod.content.other.special.attacks.ranged + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.stat.magicLvl +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** + * Division makes the Tonalztics' regular 75%-maximum-hit roll and reduces Defence by ten percent + * of the victim's current Magic level on each landed hit. Charged Tonalztics make two independent + * rolls. Against NPCs, a first landed hit drains before the second roll; against players, both + * rolls are made before either impact applies its drain. + */ +class TonalzticsOfRalosSpecialAttack @Inject constructor() : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerRanged( + "obj.tonalztics_of_ralos_uncharged", + Division(manager, hitCount = UNCHARGED_HIT_COUNT), + ) + registerRanged( + "obj.tonalztics_of_ralos_charged", + Division(manager, hitCount = CHARGED_HIT_COUNT), + ) + } + + private class Division( + private val manager: SpecialAttackManager, + private val hitCount: Int, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = division(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = division(target, attack) + + private fun ProtectedAccess.division( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + getInvObj(attack.weapon) + anim(if (hitCount == CHARGED_HIT_COUNT) CHARGED_SPECIAL_ANIM else UNCHARGED_SPECIAL_ANIM) + // Only the charged variant's spinning throw has a documented sound. + if (hitCount == CHARGED_HIT_COUNT) { + soundSynth(CHARGED_THROW_SOUND) + soundSynth(CHARGED_SPIN_SOUND, delay = CHARGED_SPIN_SOUND_DELAY) + } + spotanim( + if (hitCount == CHARGED_HIT_COUNT) CHARGED_PLAYER_SPOTANIM + else UNCHARGED_PLAYER_SPOTANIM, + ) + + val hits = ArrayList(hitCount) + repeat(hitCount) { index -> + val second = index == 1 + val projectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = if (second) SECOND_TRAVEL_SPOTANIM else FIRST_TRAVEL_SPOTANIM, + projanim = THROWN_PROJANIM, + ) + target.spotanim( + if (second) SECOND_IMPACT_SPOTANIM else FIRST_IMPACT_SPOTANIM, + delay = projectile.clientCycles, + ) + val hit = rollDivisionHit(target, attack) + if (target is Npc && hit.landed) { + target.drainDivisionDefence() + } + hits += + hit.copy( + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + } + + manager.giveCombatXp(this, target, attack, hits.sumOf { it.damage }) + hits.forEachIndexed { index, hit -> + if (index == 0) { + manager.queueRangedHit( + source = this, + target = target, + ammo = null, + damage = hit.damage, + clientDelay = hit.clientDelay, + hitDelay = hit.hitDelay, + ) + } else { + manager.queueRangedDamage( + source = this, + target = target, + ammo = null, + damage = hit.damage, + hitDelay = hit.hitDelay, + ) + } + // Real OSRS doesn't clamp damage after the roll, so the already-known hit.landed + // here is the authentic signal for the Defence drain - no impact callback needed. + if (target is Player && hit.landed) { + target.drainDivisionDefence() + } + } + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.rollDivisionHit( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): DivisionHit { + val accurate = + manager.rollRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + if (!accurate) { + return DivisionHit(landed = false, damage = 0) + } + + val normalMaxHit = + manager.calculateRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + boltSpecDamage = 0, + ) + val maxHit = TonalzticsOfRalosSpecialDamage.maxHit(normalMaxHit) + if (maxHit <= 0) { + return DivisionHit(landed = false, damage = 0) + } + return DivisionHit( + landed = true, + damage = + manager.rollRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = MAX_HIT_MULTIPLIER, + boltSpecDamage = 0, + ), + ) + } + + private data class DivisionHit( + val landed: Boolean, + val damage: Int, + val clientDelay: Int = 0, + val hitDelay: Int = 0, + ) + } + + private companion object { + const val UNCHARGED_HIT_COUNT: Int = 1 + const val CHARGED_HIT_COUNT: Int = 2 + const val MAX_HIT_MULTIPLIER: Double = 0.75 + const val CHARGED_SPECIAL_ANIM: String = "seq.human_glaive_ralos01_charged_special" + const val UNCHARGED_SPECIAL_ANIM: String = "seq.human_glaive_ralos01_uncharged_special" + const val CHARGED_PLAYER_SPOTANIM: String = "spotanim.vfx_glaive_charged_special" + const val UNCHARGED_PLAYER_SPOTANIM: String = "spotanim.vfx_glaive_uncharged_special" + const val FIRST_TRAVEL_SPOTANIM: String = "spotanim.projanim_glaive_01_special" + const val SECOND_TRAVEL_SPOTANIM: String = "spotanim.projanim_glaive_02_special" + const val FIRST_IMPACT_SPOTANIM: String = "spotanim.vfx_glaive_01_impact_special_01" + const val SECOND_IMPACT_SPOTANIM: String = "spotanim.vfx_glaive_02_impact_special_01" + const val THROWN_PROJANIM: String = "projanim.thrown" + + const val CHARGED_THROW_SOUND = 7942 + const val CHARGED_SPIN_SOUND = 7943 + + const val CHARGED_SPIN_SOUND_DELAY = 20 + } +} + +private fun PathingEntity.drainDivisionDefence() { + when (this) { + is Player -> { + val drain = TonalzticsOfRalosSpecialDamage.defenceDrain(magicLvl) + if (drain > 0) { + statSub("stat.defence", constant = drain, percent = 0) + } + } + + is Npc -> { + val drain = TonalzticsOfRalosSpecialDamage.defenceDrain(magicLvl) + defenceLvl = (defenceLvl - drain).coerceAtLeast(0) + } + } +} + +/** Pure Division values shared by all of its hit rolls. */ +internal object TonalzticsOfRalosSpecialDamage { + fun maxHit(normalMaxHit: Int): Int = normalMaxHit.coerceAtLeast(0) * 3 / 4 + + fun defenceDrain(targetMagicLevel: Int): Int = targetMagicLevel.coerceAtLeast(0) / 10 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeSpecialAttack.kt new file mode 100644 index 000000000..c0e0eda01 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeSpecialAttack.kt @@ -0,0 +1,189 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.ranged.RangedAmmunition +import org.rsmod.api.config.refs.params +import org.rsmod.api.mechanics.toxins.NpcPoisonEffectService +import org.rsmod.api.mechanics.toxins.impl.PlayerVenom +import org.rsmod.api.player.hat +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.ranged.BlowpipeAmmo +import org.rsmod.api.player.righthand +import org.rsmod.api.player.stat.hitpoints +import org.rsmod.api.player.stat.statHeal +import org.rsmod.api.player.worn.EquipmentChecks +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** + * Toxic Siphon fires one stored dart with doubled accuracy and 150% maximum damage, then restores + * half of the prospective hit. The heal uses the raw roll so overkill still heals correctly. + */ +class ToxicBlowpipeSpecialAttack +@Inject +constructor(private val random: GameRandom, private val poisons: NpcPoisonEffectService) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val toxicSiphon = ToxicSiphon(manager, random, poisons) + registerRanged("obj.toxic_blowpipe_loaded", toxicSiphon) + registerRanged("obj.toxic_blowpipe_loaded_ornament", toxicSiphon) + } + + private class ToxicSiphon( + private val manager: SpecialAttackManager, + private val random: GameRandom, + private val poisons: NpcPoisonEffectService, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = toxicSiphon(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = toxicSiphon(target, attack) + + private fun ProtectedAccess.toxicSiphon( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val equipped = player.righthand + val dart = BlowpipeAmmo.loadedDart(equipped) + if (dart == null || !BlowpipeAmmo.canUseLoadedDart(equipped)) { + manager.stopCombat(this) + mes("Your toxic blowpipe has no usable darts loaded.") + return false + } + if (!BlowpipeAmmo.hasScales(equipped, 1)) { + manager.stopCombat(this) + mes("Your toxic blowpipe has run out of scales.") + return false + } + + val travelSpotanim = dart.type.paramOrNull(params.proj_travel) + if (travelSpotanim == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + + val consumeDart = !RangedAmmunition.conserveAmmo(player, random) + val consumeScale = !random.randomBoolean(SCALE_CONSERVE_ROLL) + + // `seq.toxic_blowpipe_special_updated` (used further down for the spotanim) is + // actually the flying dart's own model animation, not a player pose - it has none of + // the replaceheldright/walkmerge markers a real player seq carries. Toxic Siphon + // reuses the weapon's ordinary throwing animation for the player instead, same as + // Rosewood blowpipe's Rapid Burst does with its own dedicated special seq. + val attackAnim = weaponType.paramOrNull(params.attack_anim_stance1) + if (attackAnim != null) { + anim(RSCM.getReverseMapping(RSCMType.SEQ, attackAnim.id)) + } + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + + // `spotanim.toxic_blowpipe_specialattack` is the special's own flying-dart effect - + // it replaces the normal dart's travel spotanim for the projectile itself, rather + // than playing as a separate effect on the player. (Previously played on the player + // at height=96, which looked like a stray effect floating overhead instead of a + // projectile heading to the target.) + // + // Uses its own dedicated `projanim.toxic_blowpipe_special` type (registered as a real + // alias in .data/gamevals/projanim.rscm, plus its own projectiles.toml entry) instead + // of the shared `projanim.thrown` (163/146) every other thrown weapon uses - that + // looked too high for this effect's own model; a first attempt reusing + // `projanim.dragonfire`'s values (43/31) was reported too low. Height values here are + // an unverified visual-tuning guess pending live confirmation. + val projectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = TOXIC_SIPHON_SPOTANIM, + projanim = TOXIC_SIPHON_PROJANIM, + ) + val damage = + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = TOXIC_SIPHON_ACCURACY_MULTIPLIER, + maxHitMultiplier = TOXIC_SIPHON_MAX_HIT_MULTIPLIER, + ) + val heal = ToxicBlowpipeSpecialDamage.heal(damage) + val guaranteedNpcVenom = target is Npc && EquipmentChecks.isSerpentineHelm(player.hat) + val venom = + damage > 0 && (guaranteedNpcVenom || random.randomBoolean(VENOM_ROLL_DENOMINATOR)) + + val consumption = + BlowpipeAmmo.consume( + player = player, + darts = if (consumeDart) 1 else 0, + scales = if (consumeScale) 1 else 0, + ) + if (consumption == null) { + manager.stopCombat(this) + return false + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = dart.type, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // Real OSRS doesn't clamp damage after the roll, so the already-known heal/venom + // outcomes above are authentic - no impact callback needed. + if (heal > 0 && player.hitpoints > 0) { + player.statHeal("stat.hitpoints", constant = heal, percent = 0) + } + if (venom) { + when (target) { + is Npc -> poisons.applyVenom(player, target) + is Player -> PlayerVenom.tryVenom(target) + } + } + + if (!consumption.canFire) { + if (consumption.dartsLeft == 0) { + mes("Your toxic blowpipe has run out of darts.") + } else { + mes("Your toxic blowpipe has run out of scales.") + } + manager.stopCombat(this) + } else { + manager.continueCombat(this, target) + } + return true + } + } + + private companion object { + const val SCALE_CONSERVE_ROLL: Int = 3 + const val VENOM_ROLL_DENOMINATOR: Int = 4 + const val TOXIC_SIPHON_ACCURACY_MULTIPLIER: Double = 2.0 + const val TOXIC_SIPHON_MAX_HIT_MULTIPLIER: Double = 1.5 + + // Confirmed via the real cache: this spotanim's own embedded "anim" is the flying dart's + // model animation, not a player pose - no separate player anim() call needed for it. + const val TOXIC_SIPHON_SPOTANIM: String = "spotanim.toxic_blowpipe_specialattack" + const val TOXIC_SIPHON_PROJANIM: String = "projanim.toxic_blowpipe_special" + } +} + +internal object ToxicBlowpipeSpecialDamage { + fun heal(prospectiveDamage: Int): Int = prospectiveDamage.coerceAtLeast(0) / 2 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowSpecialAttack.kt new file mode 100644 index 000000000..3416950ba --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowSpecialAttack.kt @@ -0,0 +1,235 @@ +package org.rsmod.content.other.special.attacks.ranged + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.CombatChargeManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.mechanics.toxins.NpcPoisonEffectService +import org.rsmod.api.mechanics.toxins.impl.PlayerPoison +import org.rsmod.api.npc.isValidTarget as isValidNpcTarget +import org.rsmod.api.obj.charges.ObjChargeManager +import org.rsmod.api.obj.charges.ObjChargeManager.Companion.isFailure +import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.isValidTarget as isValidPlayerTarget +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.random.GameRandom +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.queue.WorldQueueList +import org.rsmod.game.type.getInvObj + +/** Swarm consumes one ether and fires four independently rolled, poison-capable arrows. */ +class WebweaverBowSpecialAttack +@Inject +constructor( + private val charges: CombatChargeManager, + private val random: GameRandom, + private val poisons: NpcPoisonEffectService, + private val worldQueues: WorldQueueList, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerRanged( + "obj.wild_cave_webweaver_charged", + Swarm(manager, charges, random, poisons, worldQueues), + ) + } + + private class Swarm( + private val manager: SpecialAttackManager, + private val charges: CombatChargeManager, + private val random: GameRandom, + private val poisons: NpcPoisonEffectService, + private val worldQueues: WorldQueueList, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = swarm(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = swarm(target, attack) + + private fun ProtectedAccess.swarm( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + if (charges.getWeaponCharges(player, ETHER_VAROBJ) < 1) { + detractWebweaverCharge() + manager.stopCombat(this) + mes("Your Webweaver bow has run out of charges.") + return false + } + + anim(SWARM_SEQUENCE) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + spotanim( + SWARM_LAUNCH_SPOTANIM, + height = 0, + slot = constants.spotanim_slot_combat, + ) + + // Live feedback (confirmed against the wiki's own screenshot of the special): Swarm + // never spawns a visible flying projectile the way a normal shot does - just the + // launch spotanim on the player and the impact spotanim on the target. The previous + // version here called `manager.spawnProjectile` per hit, which rendered an actual + // arrow model traveling to the target - removed entirely, along with the `proj_type` + // resolution it needed. The first hit lands on the tick the special fires, and all + // three remaining hits land together on the next tick - a 2-tick spread, not 4 + // separate ticks (one per hit, the previous behavior here). + val hits = + List(SWARM_HIT_COUNT) { index -> + val damage = swarmDamage(target, attack) + val poison = damage > 0 && random.randomBoolean(POISON_ROLL_DENOMINATOR) + val tickOffset = if (index == 0) 0 else 1 + SwarmHit( + damage = damage, + clientDelay = tickOffset * CLIENT_CYCLES_PER_TICK, + hitDelay = 1 + tickOffset, + poison = poison, + ) + } + + val chargesLeft = + detractWebweaverCharge() + ?: run { + manager.stopCombat(this) + return false + } + + manager.giveCombatXp(this, target, attack, hits.sumOf { it.damage }) + hits.forEachIndexed { index, hit -> + target.spotanim( + SWARM_IMPACT_SPOTANIM, + delay = hit.clientDelay, + height = 0, + ) + if (index == 0) { + manager.queueRangedHit( + source = this, + target = target, + ammo = null, + damage = hit.damage, + clientDelay = hit.clientDelay, + hitDelay = hit.hitDelay, + ) + } else { + manager.queueRangedDamage( + source = this, + target = target, + ammo = null, + damage = hit.damage, + hitDelay = hit.hitDelay, + ) + } + // Real OSRS doesn't clamp damage after the roll, so the already-known poison + // outcome (rolled alongside damage above) is authentic - no impact callback needed. + // Deferred to match this specific hit's own landing tick (hitDelay) rather than + // applying at cast time for every hit regardless of when it actually lands - hits + // 2-4 land a tick after hit 1, so their poison shouldn't start counting early. + if (hit.poison) { + worldQueues.add(hit.hitDelay) { + if (target.isStillValidTarget()) { + applyPoison(player, target) + } + } + } + } + + if (chargesLeft == 0) { + mes("Your Webweaver bow has run out of charges.") + manager.stopCombat(this) + } else { + manager.continueCombat(this, target) + } + return true + } + + private fun ProtectedAccess.detractWebweaverCharge(): Int? { + val result = charges.attemptDetractWeapon(player, ETHER_VAROBJ) + if (result.isFailure()) { + return null + } + return (result as ObjChargeManager.Uncharge.Success).chargesLeft + } + + private fun ProtectedAccess.swarmDamage( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Int { + val accurate = + manager.rollRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = SWARM_ACCURACY_MULTIPLIER, + ) + if (!accurate) { + return 0 + } + + val normalMaxHit = + manager.calculateRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + boltSpecDamage = 0, + ) + val swarmMaxHit = WebweaverBowSpecialDamage.maxHit(normalMaxHit) + if (swarmMaxHit <= 0) { + return 0 + } + return if (player.adminMaxHit) swarmMaxHit else random.of(1..swarmMaxHit) + } + + private fun applyPoison(source: Player, target: PathingEntity) { + when (target) { + is Npc -> poisons.apply(source, target, WEBWEAVER_POISON_DAMAGE) + is Player -> PlayerPoison.tryPoison(target, initialDamage = WEBWEAVER_POISON_DAMAGE) + } + } + + private data class SwarmHit( + val damage: Int, + val clientDelay: Int, + val hitDelay: Int, + val poison: Boolean, + ) + + private fun PathingEntity.isStillValidTarget(): Boolean = + when (this) { + is Npc -> isValidNpcTarget() + is Player -> isValidPlayerTarget() + } + } + + private companion object { + const val ETHER_VAROBJ: String = "varobj.charges_16383" + const val SWARM_HIT_COUNT: Int = 4 + const val SWARM_ACCURACY_MULTIPLIER: Double = 2.0 + const val POISON_ROLL_DENOMINATOR: Int = 4 + const val WEBWEAVER_POISON_DAMAGE: Int = 4 + const val CLIENT_CYCLES_PER_TICK: Int = 30 + const val SWARM_SEQUENCE: String = "seq.human_special01_webweaver" + const val SWARM_LAUNCH_SPOTANIM: String = "spotanim.fx_webweaver01_launch_spotanim" + const val SWARM_IMPACT_SPOTANIM: String = "spotanim.fx_webweaver01_impact_spotanim" + } +} + +internal object WebweaverBowSpecialDamage { + /** Ceiling of forty percent of the normal maximum hit. */ + fun maxHit(normalMaxHit: Int): Int = + ((normalMaxHit.coerceAtLeast(0) * 2) + 4) / 5 +} diff --git a/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialAttack.kt new file mode 100644 index 000000000..7a0e0894c --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialAttack.kt @@ -0,0 +1,284 @@ +package org.rsmod.content.other.special.attacks.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import kotlin.math.min +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatEffects +import org.rsmod.api.combat.commons.DragonfireProtection +import org.rsmod.api.combat.manager.RangedAmmoManager +import org.rsmod.api.config.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.quiver +import org.rsmod.api.player.stat.hitpoints +import org.rsmod.api.player.stat.prayerLvl +import org.rsmod.api.player.stat.rangedLvl +import org.rsmod.api.player.stat.statHeal +import org.rsmod.api.player.stat.statSub +import org.rsmod.api.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.RangedSpecialAttack +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.type.getInvObj +import org.rsmod.game.type.getOrNull + +/** + * Evoke is an ordinary crossbow shot with double accuracy. When that accuracy roll succeeds, an + * equipped enchanted bolt's effect is forced for this shot. This deliberately retains the normal + * quiver, projectile, impact, and ammunition paths instead of replacing a bolt with a generic + * ranged hit. + */ +class ZaryteCrossbowSpecialAttack +@Inject +constructor(private val ammunition: RangedAmmoManager) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val evoke = Evoke(manager, ammunition) + registerRanged("obj.zaryte_xbow", evoke) + registerRanged("obj.br_zaryte_xbow", evoke) + } + + private class Evoke( + private val manager: SpecialAttackManager, + private val ammunition: RangedAmmoManager, + ) : RangedSpecialAttack { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = evoke(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = evoke(target, attack) + + private fun ProtectedAccess.evoke( + target: PathingEntity, + attack: CombatAttack.Ranged, + ): Boolean { + val weaponType = getInvObj(attack.weapon) + val quiverType = getOrNull(player.quiver) + if (!ammunition.attemptAmmoUsage(player, weaponType, quiverType)) { + manager.stopCombat(this) + return false + } + + val projectileType = weaponType.paramOrNull(params.proj_type) + if (quiverType == null || projectileType == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return false + } + anim(if (target is Npc) "seq.zcb_attack_pvn" else "seq.zcb_attack") + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + + val landed = + manager.rollRangedAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = EVOKE_ACCURACY_MULTIPLIER, + ) + val bolt = + ArmadylEnchantedBolt + .from(quiverType) + ?.takeIf { it.isEffectiveAgainst(target) } + val damage = if (landed) evokeDamage(target, attack, bolt) else 0 + val projectile = + manager.spawnProjectile( + source = this, + target = target, + spotanim = "spotanim.zcb_specialattack", + projanim = RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), + ) + + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = projectile.serverCycles, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit( + source = this, + target = target, + ammo = quiverType, + damage = damage, + clientDelay = projectile.clientCycles, + hitDelay = projectile.serverCycles, + ) + // Real OSRS doesn't clamp damage after the roll, so the already-known damage here is + // the authentic value for every bolt effect below - no impact callback needed. + if (landed) { + bolt?.zaryteApplyEffect(player, target, damage) + } + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.evokeDamage( + target: PathingEntity, + attack: CombatAttack.Ranged, + bolt: ArmadylEnchantedBolt?, + ): Int { + if (bolt == ArmadylEnchantedBolt.Ruby) { + val selfDamage = ZaryteCrossbowSpecialDamage.rubySelfDamage(player.hitpoints) + if (selfDamage > 0) { + // A real hit (not statSub) so a hitsplat actually shows on the shooter. The + // blood-sacrifice visual itself is played by zaryteApplyEffect below (falls + // through to ArmadylEnchantedBolt.applyEffect), on the target. + takeInstantHit(type = HitType.Typeless, damage = selfDamage) + } + return ZaryteCrossbowSpecialDamage.rubyDamage(target.zaryteCurrentHitpoints()) + } + + return manager.rollRangedMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = bolt?.zaryteMaxHitMultiplier ?: 1.0, + boltSpecDamage = bolt?.zaryteBonusDamage(player, target) ?: 0, + ) + } + } + + private companion object { + const val EVOKE_ACCURACY_MULTIPLIER: Double = 2.0 + } +} + +/** + * Zaryte's passive bonus applies to a forced bolt effect too. The normal Zaryte attack path can + * continue to resolve its ordinary bolt procs separately; this keeps Evoke's guaranteed effect + * exact without changing Armadyl Eye's behavior. + */ +private fun ArmadylEnchantedBolt.zaryteBonusDamage( + source: Player, + target: PathingEntity, +): Int = + when (this) { + ArmadylEnchantedBolt.Opal -> ZaryteCrossbowSpecialDamage.opalBonus(source.rangedLvl) + ArmadylEnchantedBolt.Pearl -> + ZaryteCrossbowSpecialDamage.pearlBonus( + visibleRangedLevel = source.rangedLvl, + fieryTarget = target.isZaryteFiery(), + ) + + ArmadylEnchantedBolt.Dragonstone -> { + val base = ZaryteCrossbowSpecialDamage.dragonstoneBonus(source.rangedLvl) + if (target is Player) { + DragonfireProtection.resolveMaxHit( + player = target, + type = DragonfireProtection.DragonfireType.Chromatic, + baseMax = base, + ) + } else { + base + } + } + + else -> 0 + } + +private val ArmadylEnchantedBolt.zaryteMaxHitMultiplier: Double + get() = + when (this) { + ArmadylEnchantedBolt.Diamond -> ZaryteCrossbowSpecialDamage.DIAMOND_MAX_HIT_MULTIPLIER + ArmadylEnchantedBolt.Onyx -> ZaryteCrossbowSpecialDamage.ONYX_MAX_HIT_MULTIPLIER + else -> 1.0 + } + +/** + * Real OSRS doesn't clamp damage after the roll, so [damage] (the already-known, pre-clamp value) + * is the authentic figure for every bolt effect below - no impact callback needed. Every enchanted + * bolt effect has its own dedicated activation spotanim (see [ArmadylEnchantedBolt.spotanim]), + * played on the target - the branches below that don't override it fall through to + * [ArmadylEnchantedBolt.applyEffect], which plays it. + */ +private fun ArmadylEnchantedBolt.zaryteApplyEffect( + source: Player, + target: PathingEntity, + damage: Int, +) { + when (this) { + ArmadylEnchantedBolt.Sapphire -> { + val player = target as? Player ?: return + if (damage <= 0) { + return + } + target.spotanim(spot = spotanim, height = 0) + val requested = ZaryteCrossbowSpecialDamage.sapphirePrayerDrain(source.rangedLvl) + val drained = min(requested, player.prayerLvl) + if (drained <= 0) { + return + } + player.statSub("stat.prayer", constant = drained, percent = 0) + source.statHeal( + "stat.prayer", + constant = ZaryteCrossbowSpecialDamage.sapphirePrayerRestore(drained), + percent = 0, + ) + } + + ArmadylEnchantedBolt.Emerald -> { + val player = target as? Player ?: return + if (damage > 0) { + target.spotanim(spot = spotanim, height = 0) + CombatEffects.poison(player, ZaryteCrossbowSpecialDamage.EMERALD_POISON_DAMAGE) + } + } + + else -> applyEffect(source, target, damage) + } +} + +private fun PathingEntity.zaryteCurrentHitpoints(): Int = + when (this) { + is Npc -> hitpoints + is Player -> hitpoints + } + +private fun PathingEntity.isZaryteFiery(): Boolean = + this is Npc && + ( + (visType.paramOrNull(params.draconic) ?: 0) != 0 || + (visType.paramOrNull(params.elemental_weakness_type) ?: -1) == + constants.elemental_weakness_water + ) + +/** Pure values for Evoke's Zaryte-strengthened enchanted-bolt effects. */ +internal object ZaryteCrossbowSpecialDamage { + const val DIAMOND_MAX_HIT_MULTIPLIER: Double = 1.26 + const val ONYX_MAX_HIT_MULTIPLIER: Double = 1.32 + const val EMERALD_POISON_DAMAGE: Int = 6 + + fun rubyDamage(targetHitpoints: Int): Int = + min(targetHitpoints.coerceAtLeast(0) * 22 / 100, RUBY_DAMAGE_CAP) + + fun rubySelfDamage(sourceHitpoints: Int): Int = sourceHitpoints.coerceAtLeast(0) / 10 + + fun opalBonus(visibleRangedLevel: Int): Int = visibleRangedLevel.coerceAtLeast(0) / 9 + + fun pearlBonus( + visibleRangedLevel: Int, + fieryTarget: Boolean, + ): Int = visibleRangedLevel.coerceAtLeast(0) / if (fieryTarget) 13 else 18 + + fun dragonstoneBonus(visibleRangedLevel: Int): Int = + visibleRangedLevel.coerceAtLeast(0) * 22 / 100 + + fun sapphirePrayerDrain(visibleRangedLevel: Int): Int = + visibleRangedLevel.coerceAtLeast(0) / 18 + + fun sapphirePrayerRestore(drainedPrayer: Int): Int = drainedPrayer.coerceAtLeast(0) / 2 + + private const val RUBY_DAMAGE_CAP: Int = 110 +} diff --git a/content/other/special-weapons/build.gradle.kts b/content/other/special-weapons/build.gradle.kts index 09da26e5b..25066146e 100644 --- a/content/other/special-weapons/build.gradle.kts +++ b/content/other/special-weapons/build.gradle.kts @@ -4,6 +4,8 @@ plugins { dependencies { implementation(projects.api.combat.combatManager) + implementation(projects.api.death) + implementation(projects.api.mechanics.toxins) implementation(projects.api.pluginCommons) implementation(projects.api.repo) implementation(projects.api.weapons) diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/MeleeWeaponsModule.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/MeleeWeaponsModule.kt index 24cdde11a..d5a5e0559 100644 --- a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/MeleeWeaponsModule.kt +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/MeleeWeaponsModule.kt @@ -1,9 +1,16 @@ package org.rsmod.content.other.special.weapons import org.rsmod.api.weapons.WeaponMap +import org.rsmod.content.other.special.weapons.melee.CrimsonKistenWeapons +import org.rsmod.content.other.special.weapons.melee.DogswordWeapons import org.rsmod.content.other.special.weapons.melee.DualMacuahuitlWeapons +import org.rsmod.content.other.special.weapons.melee.FangOfTheHoundWeapons import org.rsmod.content.other.special.weapons.melee.MultiHitMeleeWeapons +import org.rsmod.content.other.special.weapons.melee.NoxiousHalberdWeapons import org.rsmod.content.other.special.weapons.melee.ScytheOfViturWeapons +import org.rsmod.content.other.special.weapons.melee.SoulreaperAxeWeapons +import org.rsmod.content.other.special.weapons.melee.SunspearWeapons +import org.rsmod.content.other.special.weapons.melee.ThunderKhopeshWeapons import org.rsmod.plugin.module.PluginModule class MeleeWeaponsModule : PluginModule() { @@ -11,5 +18,12 @@ class MeleeWeaponsModule : PluginModule() { addSetBinding(ScytheOfViturWeapons::class.java) addSetBinding(MultiHitMeleeWeapons::class.java) addSetBinding(DualMacuahuitlWeapons::class.java) + addSetBinding(SoulreaperAxeWeapons::class.java) + addSetBinding(ThunderKhopeshWeapons::class.java) + addSetBinding(FangOfTheHoundWeapons::class.java) + addSetBinding(CrimsonKistenWeapons::class.java) + addSetBinding(DogswordWeapons::class.java) + addSetBinding(SunspearWeapons::class.java) + addSetBinding(NoxiousHalberdWeapons::class.java) } } diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/RangedWeaponsModule.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/RangedWeaponsModule.kt index ca6fedc89..9bbfe898d 100644 --- a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/RangedWeaponsModule.kt +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/RangedWeaponsModule.kt @@ -1,11 +1,17 @@ package org.rsmod.content.other.special.weapons import org.rsmod.api.weapons.WeaponMap +import org.rsmod.content.other.special.weapons.ranged.BlowpipeWeapons +import org.rsmod.content.other.special.weapons.ranged.CrawsBowWeapons import org.rsmod.content.other.special.weapons.ranged.DarkBowWeapons +import org.rsmod.content.other.special.weapons.ranged.WebweaverBowWeapons import org.rsmod.plugin.module.PluginModule class RangedWeaponsModule : PluginModule() { override fun bind() { + addSetBinding(BlowpipeWeapons::class.java) + addSetBinding(CrawsBowWeapons::class.java) addSetBinding(DarkBowWeapons::class.java) + addSetBinding(WebweaverBowWeapons::class.java) } } diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/CrimsonKistenWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/CrimsonKistenWeapons.kt new file mode 100644 index 000000000..c261c860a --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/CrimsonKistenWeapons.kt @@ -0,0 +1,92 @@ +package org.rsmod.content.other.special.weapons.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatStance +import org.rsmod.api.player.output.soundSynth +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.weapons.MeleeWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Ordinary attacks (Pound/Pummel/Spike/Block) - the item's own cache-defined bonuses, no bespoke + * accuracy or damage. No passive; this weapon is used almost exclusively for its special attack + * (see `CrimsonKistenSpecialAttack.kt`). + * + * The cache entry for `obj.crimson_kisten` (a June 2026 Maggot King drop) originally had no + * `weaponCategory` at all, which is why the combat-style tab showed Punch/Kick/Block instead of + * this weapon's real styles - that part can only be fixed in the cache (added a + * `weaponCategory="Spiked"` override), since the client reads it directly for that tab. The + * animation itself is still played directly here rather than relying on + * `WeaponAttackManager.playWeaponFx`'s cache-param lookup, using the weapon's own real, named + * animations (`seq.human_weapons_crimson_kisten_*`) - per the wiki's own trivia, the crush styles + * use the newer "attack" animation while the stab style deliberately kept the original + * "attack_alt" animation after a post-release hotfix. + */ +class CrimsonKistenWeapons @Inject constructor() : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val kisten = CrimsonKisten(manager) + register("obj.crimson_kisten", kisten) + } + + private class CrimsonKisten(private val manager: WeaponAttackManager) : MeleeWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + private fun ProtectedAccess.swing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + playSwingFx(attack) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.playSwingFx(attack: CombatAttack.Melee) { + // This hardcodes the animation directly, bypassing the item's own cache + // attack_anim_stance* params entirely - meaning changing those params in items.toml + // has zero visible effect for this weapon's normal attack. That cost a lot of wasted + // rounds live-testing before this file itself was rechecked. Stance4 (Block) used to + // play DEF_ANIM here on the theory that it was the correct Block-stance swing (torka's + // own notes label 14257 "Defend"), but live feedback was clear it looks wrong - + // reusing the regular attack swing instead. + val anim = + when (attack.stance) { + CombatStance.Stance3 -> ATTACK_ALT_ANIM + else -> ATTACK_ANIM + } + player.anim(anim, priority = 6) + player.soundSynth(SWING_SOUND) + } + } + + private companion object { + // Real named sequences (see `seq.human_weapons_crimson_kisten_*`); no `attack_anim_stance*` + // param aliases exist in this revision's cache for this item. + const val ATTACK_ANIM: String = "seq.human_weapons_crimson_kisten_attack" + const val ATTACK_ALT_ANIM: String = "seq.human_weapons_crimson_kisten_attack_alt" + const val SWING_SOUND: Int = 401 + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/DogswordWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/DogswordWeapons.kt new file mode 100644 index 000000000..ec581343b --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/DogswordWeapons.kt @@ -0,0 +1,87 @@ +package org.rsmod.content.other.special.weapons.melee + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatStance +import org.rsmod.api.player.output.soundSynth +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.weapons.MeleeWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Ordinary attacks (Chop/Slash/Smash/Block) - the item's own cache-defined bonuses, no bespoke + * accuracy or damage. No passive; this weapon is used almost exclusively for its special attack + * (see `DogswordSpecialAttack.kt`). + * + * `obj.echo_godsword`/`obj.deadman_dogsword` share the exact same real Bandos/Saradomin/Zamorak + * godsword animation *IDs* (7045/7054/7055) as the actual godswords - this cache revision just + * never gave them named RSCM aliases the way it did for a plain 2h sword's animations, so they + * have to be referenced by raw ID via `RSCM.getReverseMapping` instead of a normal string param. + * (An earlier fix here reused a generic `TwoHandedSword` swing instead, which compiled and fixed + * the "Category: Unarmed" bug but still looked like a plain sword rather than a godsword swing.) + */ +class DogswordWeapons @Inject constructor() : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val dogsword = Dogsword(manager) + register("obj.echo_godsword", dogsword) + register("obj.deadman_dogsword", dogsword) + } + + private class Dogsword(private val manager: WeaponAttackManager) : MeleeWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + private fun ProtectedAccess.swing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + playSwingFx(attack) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.playSwingFx(attack: CombatAttack.Melee) { + val (animId, soundId) = + when (attack.stance) { + CombatStance.Stance3 -> SMASH_ANIM to SMASH_SOUND + CombatStance.Stance4 -> BLOCK_ANIM to SLASH_SOUND + else -> SLASH_ANIM to SLASH_SOUND + } + player.anim(RSCM.getReverseMapping(RSCMType.SEQ, animId), priority = 6) + player.soundSynth(soundId) + } + } + + private companion object { + // Raw godsword animation IDs (see class doc) - no named RSCM alias exists in this cache. + const val SLASH_ANIM: Int = 7045 + const val SMASH_ANIM: Int = 7054 + const val BLOCK_ANIM: Int = 7055 + const val SLASH_SOUND: Int = 3847 + const val SMASH_SOUND: Int = 3846 + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/FangOfTheHoundWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/FangOfTheHoundWeapons.kt new file mode 100644 index 000000000..ae06b707c --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/FangOfTheHoundWeapons.kt @@ -0,0 +1,147 @@ +package org.rsmod.content.other.special.weapons.melee + +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.magic.Spellbook +import org.rsmod.api.config.constants +import org.rsmod.api.player.output.soundSynth +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.random.GameRandom +import org.rsmod.api.weapons.MeleeWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Ordinary attacks (Stab/Lunge/Slash/Block) - the item's own cache-defined bonuses, no bespoke + * accuracy or damage. The weapon's only unusual behavior outside its special attack (see + * `FangOfTheHoundSpecialAttack.kt`) is its passive. Wiki: "Every hit with the Fang of the Hound has + * a 5% chance to cast Flames of Cerberus at the target, which is a fire spell with a base max hit + * of 10... When the effect is triggered, the magic damage is dealt without needing to pass a second + * accuracy check." + * + * The cache entry for `obj.fang_of_the_hound` originally had no `weaponCategory` at all, which is + * why the combat-style tab showed Punch/Kick/Block instead of this weapon's real styles - that + * part can only be fixed in the cache (added a `weaponCategory="StabSword"` override), since the + * client reads it directly for that tab. The animation itself is still played directly here rather + * than relying on `WeaponAttackManager.playWeaponFx`'s cache-param lookup. + */ +class FangOfTheHoundWeapons +@Inject +constructor( + private val random: GameRandom, +) : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val fang = FangOfTheHound(manager, random) + register("obj.fang_of_the_hound", fang) + } + + private class FangOfTheHound( + private val manager: WeaponAttackManager, + private val random: GameRandom, + ) : MeleeWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + private fun ProtectedAccess.swing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + playSwingFx() + + val accurate = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + val damage = + if (accurate) { + manager.rollMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + } else { + 0 + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + if (accurate && random.of(100) < PASSIVE_PROC_CHANCE_PERCENT) { + flamesOfCerberus(target) + } + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.flamesOfCerberus(target: PathingEntity) { + val damage = + manager.rollSpellMaxHit( + source = this, + target = target, + spell = FLAMES_OF_CERBERUS, + spellbook = Spellbook.Standard, + baseMaxHit = FLAMES_BASE_MAX_HIT, + attackRate = FANG_ATTACK_RATE, + ) + + // Reported too high at 96 (same blind-copy-from-Dragon-claws pattern as the special + // attacks) - lowered to ground level per live feedback. + target.spotanim( + spot = "spotanim.vfx_flames_of_cerberus", + slot = constants.spotanim_slot_combat, + height = 0, + ) + manager.queueMagicHit( + source = this, + target = target, + damage = damage, + clientDelay = 0, + spell = FLAMES_OF_CERBERUS, + ) + } + + /** + * The cache entry for `obj.fang_of_the_hound` (a Demonic Pacts League exclusive, same + * situation as Thunder khopesh) is missing `attack_anim_stance*`/`attack_sound_stance*` + * params entirely, so `WeaponAttackManager.playWeaponFx` has nothing to read and falls back + * to the generic unarmed punch/kick. Played directly here instead, with no cache dependency + * at all - the item's own real animation (`seq.human_karambit_attack`), the non-special + * counterpart to its special attack's `seq.human_karambit_spec` (same weapon swing, without + * the special's swipe-up finish). Used for all four stances; gameval has no separate + * per-style variant, matching how the special attack itself only has the one animation too. + */ + private fun ProtectedAccess.playSwingFx() { + player.anim(SWING_ANIM, priority = 6) + player.soundSynth(SWING_SOUND) + } + } + + private companion object { + const val SWING_ANIM: String = "seq.human_karambit_attack" + const val SWING_SOUND: Int = 2549 + const val PASSIVE_PROC_CHANCE_PERCENT: Int = 5 + const val FANG_ATTACK_RATE: Int = 3 + const val FLAMES_BASE_MAX_HIT: Int = 10 + + // Cache item: obj.fang_of_the_hound_fire (id 33377). + val FLAMES_OF_CERBERUS: ItemServerType = ItemServerType(33377) + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/NoxiousHalberdWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/NoxiousHalberdWeapons.kt new file mode 100644 index 000000000..64d1be726 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/NoxiousHalberdWeapons.kt @@ -0,0 +1,123 @@ +package org.rsmod.content.other.special.weapons.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.mechanics.toxins.NoxiousHalberdVirulence +import org.rsmod.api.mechanics.toxins.NpcPoisonEffectService +import org.rsmod.api.mechanics.toxins.ToxinImmunity +import org.rsmod.api.mechanics.toxins.impl.PlayerVenom +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.random.GameRandom +import org.rsmod.api.weapons.MeleeWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Wiki: "33% chance (50% with a Serpentine helm equipped) to envenom the target (if not venom + * immune)". Also consumes [NoxiousHalberdVirulence]'s pending minimum-hit boost on the next + * accurate hit - the special attack (`NoxiousHalberdSpecialAttack.kt`) already set that buff + * correctly, but nothing ever consumed it, since this weapon had no normal-attack registration at + * all before this fix (live-testing turned this up). + * + * Known gap: real Virulence loses its buff on weapon-switch/logout; this doesn't implement that, + * since there's no generic "on weapon change" hook anywhere in this codebase to attach it to. + * `NoxiousHalberdVirulence.consume` only ever gets called from this weapon's own attack code + * though, so the buff can't leak onto a different weapon - it can only linger unconsumed if the + * player stops using this halberd, which is a much smaller gap than a real exploit. + */ +class NoxiousHalberdWeapons +@Inject +constructor( + private val random: GameRandom, + private val poisons: NpcPoisonEffectService, +) : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val halberd = NoxiousHalberd(manager, random, poisons) + register("obj.noxious_halberd", halberd) + register("obj.br_noxious_halberd", halberd) + } + + private class NoxiousHalberd( + private val manager: WeaponAttackManager, + private val random: GameRandom, + private val poisons: NpcPoisonEffectService, + ) : MeleeWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + private fun ProtectedAccess.swing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + manager.playWeaponFx(this, attack) + + val accurate = + manager.rollMeleeAccuracy( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + blockType = attack.type, + multiplier = 1.0, + ) + val damage = if (accurate) rolledDamage(target, attack) else 0 + + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + + if (accurate && random.of(100) < envenomChancePercent()) { + envenomTarget(target) + } + + manager.continueCombat(this, target) + return true + } + + /** + * Wiki: on Virulence's pending boost, "the next accurate attack will do a damage roll + * between [the cured damage] and their max hit" - a true uniform roll across that whole + * range, not the normal `rollMeleeMaxHit` roll floored up to the minimum (which would + * bias the distribution towards the minimum instead). + */ + private fun ProtectedAccess.rolledDamage( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Int { + val minimum = NoxiousHalberdVirulence.consume(player) ?: return normalMaxHit(target, attack) + val maxHit = manager.calculateMeleeMaxHit(this, target, attack.type, attack.style, 1.0) + return if (minimum >= maxHit) maxHit else random.of(minimum..maxHit) + } + + private fun ProtectedAccess.normalMaxHit(target: PathingEntity, attack: CombatAttack.Melee): Int = + manager.rollMeleeMaxHit(this, target, attack.type, attack.style, 1.0) + + private fun ProtectedAccess.envenomChancePercent(): Int = if (hasSerpentineHelm()) 50 else 33 + + private fun ProtectedAccess.hasSerpentineHelm(): Boolean = + "obj.serpentine_helm_charged" in player.worn || + "obj.serpentine_helm_charged_cyan" in player.worn || + "obj.serpentine_helm_charged_red" in player.worn + + private fun ProtectedAccess.envenomTarget(target: PathingEntity) { + when (target) { + is Npc -> poisons.applyVenom(player, target) + is Player -> { + if (!ToxinImmunity.hasVenomImmunity(target)) { + PlayerVenom.tryVenom(target) + } + } + } + } + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperAxeWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperAxeWeapons.kt new file mode 100644 index 000000000..5918283fe --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperAxeWeapons.kt @@ -0,0 +1,84 @@ +package org.rsmod.content.other.special.weapons.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.mechanics.toxins.SoulreaperStackDecay +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.vars.VarPlayerIntMapSetter +import org.rsmod.api.weapons.MeleeWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Ordinary Crush attacks; the axe's only unusual behavior is Soul Stacks. Wiki: every attack with + * the axe generates a stack (even on a miss) until five are held, applied after this swing's own + * damage is calculated - so the swing that generates a stack doesn't benefit from the Strength + * bonus it just earned. The Strength bonus itself is read directly off the stack count elsewhere + * (`MeleeMaxHitOperations.calculateEffectiveStrength`); this file only owns generating/capping the + * stack and resetting the decay timer (`SoulreaperStackDecay`, `SoulreaperStackDecayScript`). + */ +class SoulreaperAxeWeapons @Inject constructor() : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val axe = SoulreaperAxe(manager) + register("obj.soulreaper", axe) + register("obj.soulreaper_axe_orn", axe) + } + + private class SoulreaperAxe(private val manager: WeaponAttackManager) : MeleeWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + private fun ProtectedAccess.swing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + manager.playWeaponFx(this, attack) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + gainSoulStack() + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.gainSoulStack() { + val stacks = vars[SOUL_STACKS].coerceIn(0, MAX_SOUL_STACKS) + val next = SoulreaperStackGain.nextStackCount(stacks, MAX_SOUL_STACKS) + if (next != stacks) { + VarPlayerIntMapSetter.set(player, SOUL_STACKS, next) + } + // Attacking with the axe at all is what keeps stacks alive, independent of whether + // this particular swing was capped out and generated nothing. + SoulreaperStackDecay.reset(player) + } + } + + private companion object { + const val SOUL_STACKS = "varp.soulreaper_stacks" + const val MAX_SOUL_STACKS = 5 + } +} + +/** Pure Soul Stack cap math, kept separate from [ProtectedAccess] so it can be unit tested. */ +internal object SoulreaperStackGain { + /** Wiki: a stack is generated on every attack, up to a maximum of five. */ + fun nextStackCount(current: Int, max: Int): Int = (current + 1).coerceAtMost(max) +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SunspearWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SunspearWeapons.kt new file mode 100644 index 000000000..84765beaa --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SunspearWeapons.kt @@ -0,0 +1,88 @@ +package org.rsmod.content.other.special.weapons.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatStance +import org.rsmod.api.player.output.soundSynth +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.weapons.MeleeWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Ordinary attacks (Lunge/Swipe/Pound/Block) - the item's own cache-defined bonuses (including its + * vampyre accuracy/damage bonus, already read generically off cache params), no bespoke special + * multiplier. No passive; this weapon is used mostly for its special attack (see + * `SunspearSpecialAttack.kt`). + * + * The cache entry for `obj.sunspear` (a Blood Moon Rises quest reward, June 2026) is missing + * `attack_anim_stance*`/`attack_sound_stance*` params entirely, same situation as Thunder khopesh, + * Fang of the Hound, and Crimson kisten, so `WeaponAttackManager.playWeaponFx` has nothing to read + * and falls back to the generic unarmed punch/kick. Played directly here instead. Unlike those + * three, gameval only has a named animation for Sunspear's *special* attack + * (`seq.human_weapons_sunspear_spec`) - its normal swing was never given a unique animation, so + * this reuses the generic Spear-category animations real spears (e.g. the rune spear) fall back to. + */ +class SunspearWeapons @Inject constructor() : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val spear = Sunspear(manager) + register("obj.sunspear", spear) + } + + private class Sunspear(private val manager: WeaponAttackManager) : MeleeWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + private fun ProtectedAccess.swing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + playSwingFx(attack) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + manager.continueCombat(this, target) + return true + } + + private fun ProtectedAccess.playSwingFx(attack: CombatAttack.Melee) { + val (anim, soundId) = + when (attack.stance) { + CombatStance.Stance2 -> SWIPE_ANIM to SWIPE_SOUND + CombatStance.Stance3 -> POUND_ANIM to POUND_SOUND + else -> LUNGE_ANIM to LUNGE_SOUND + } + player.anim(anim, priority = 6) + player.soundSynth(soundId) + } + } + + private companion object { + // Generic Spear-category animations (matches the rune spear's own fallback set); no + // dedicated normal-attack sequence exists for the sunspear model itself, only its special. + const val LUNGE_ANIM: String = "seq.human_spear_spike" + const val LUNGE_SOUND: Int = 2562 + const val SWIPE_ANIM: String = "seq.human_scythe_sweep" + const val SWIPE_SOUND: Int = 2556 + const val POUND_ANIM: String = "seq.human_spear_lunge" + const val POUND_SOUND: Int = 2555 + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/ThunderKhopeshWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/ThunderKhopeshWeapons.kt new file mode 100644 index 000000000..8e64c5025 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/ThunderKhopeshWeapons.kt @@ -0,0 +1,168 @@ +package org.rsmod.content.other.special.weapons.melee + +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.CombatStance +import org.rsmod.api.death.NpcAttackValidateHook +import org.rsmod.api.death.NpcAttackValidateResult +import org.rsmod.api.npc.hit.modifier.NpcHitModifier +import org.rsmod.api.npc.hit.queueHit +import org.rsmod.api.npc.isValidTarget +import org.rsmod.api.player.output.soundSynth +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.random.GameRandom +import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.weapons.MeleeWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.hit.HitType +import org.rsmod.game.interact.InteractionOp +import org.rsmod.game.queue.WorldQueueList +import org.rsmod.map.CoordGrid +import org.rsmod.map.zone.ZoneKey + +/** + * Ordinary attacks (Chop/Slash/Lunge/Block) - the item's own cache-defined bonuses, no bespoke + * accuracy or damage. The weapon's only unusual behavior outside its special attack (see + * `ThunderKhopeshSpecialAttack.kt`) is its passive. Wiki: "grants standard, successful hits a 20% + * chance to summon a delayed lightning bolt at the target's location, damaging enemies in a 3x3 + * area for up to 50% of the player's max hit... The lightning bolt does not roll accuracy checks... + * No experience is gained from the damage dealt by the lightning bolts." + * + * NPC-only, matching the special attack's own PvM lightning branch + * (`ThunderKhopeshLightning.schedulePvm`, duplicated here rather than shared since this module + * doesn't otherwise depend on `special-attacks`) - the wiki never documents a PvP variant of the + * passive, and this weapon was PvM-exclusive Leagues/Grid Master content. + */ +class ThunderKhopeshWeapons +@Inject +constructor( + private val worldQueues: WorldQueueList, + private val random: GameRandom, + private val npcs: NpcRepository, + private val npcHitModifier: NpcHitModifier, + private val npcAttackValidateHooks: Set, +) : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val khopesh = ThunderKhopesh(manager, this@ThunderKhopeshWeapons) + register("obj.thunder_khopesh", khopesh) + register("obj.deadman_thunder_khopesh", khopesh) + } + + private class ThunderKhopesh( + private val manager: WeaponAttackManager, + private val passive: ThunderKhopeshWeapons, + ) : MeleeWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean = swing(target, attack) + + private fun ProtectedAccess.swing( + target: PathingEntity, + attack: CombatAttack.Melee, + ): Boolean { + playSwingFx(attack) + val maximum = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + val damage = + manager.rollMeleeDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = 1.0, + maxHitMultiplier = 1.0, + ) + manager.giveCombatXp(this, target, attack, damage) + manager.queueMeleeHit(this, target, damage) + if (damage > 0 && random.of(100) < PASSIVE_PROC_CHANCE_PERCENT) { + val maximumLightningHit = maximum * PASSIVE_LIGHTNING_MAX_HIT_PERCENT / 100 + passive.scheduleLightning(player, target.coords, maximumLightningHit) + } + manager.continueCombat(this, target) + return true + } + + /** + * Both Thunder khopesh variants' cache entries are missing `attack_anim_stance*`/ + * `attack_sound_stance*` params entirely, so `WeaponAttackManager.playWeaponFx` has nothing + * to read and falls back to the generic unarmed punch/kick. Played directly here instead, + * with no cache dependency at all - same real animations/sounds Dragon scimitar uses (an + * identical SlashSword weapon per the wiki's own combat-style table: Chop/Slash/Lunge/ + * Block). + */ + private fun ProtectedAccess.playSwingFx(attack: CombatAttack.Melee) { + val stab = attack.stance == CombatStance.Stance3 + val anim = if (stab) "seq.human_sword_stab" else "seq.human_sword_slash" + val soundId = if (stab) STAB_SOUND else SLASH_SOUND + player.anim(anim, priority = 6) + player.soundSynth(soundId) + } + } + + private fun scheduleLightning(source: Player, centre: CoordGrid, maximumHit: Int) { + if (maximumHit <= 0) { + return + } + val sourceUid = source.uid.packed + worldQueues.add(LIGHTNING_DELAY) { + if (!source.isSlotAssigned || source.uid.packed != sourceUid) { + return@add + } + + val zone = ZoneKey.from(centre) + for (target in npcs.findAll(zone, zoneRadius = SEARCH_ZONE_RADIUS)) { + if (!target.occupiesLightningTile(centre) || !canAttack(source, target)) { + continue + } + target.spotanim("spotanim.fx_khopesh_lightning_special_extra") + val damage = random.of(0..maximumHit) + target.queueHit( + source = source, + delay = TARGET_HIT_DELAY, + type = HitType.Typeless, + damage = damage, + modifier = npcHitModifier, + ) + } + } + } + + private fun canAttack(source: Player, target: Npc): Boolean { + if (!target.isValidTarget() || !target.visType.hasOp(InteractionOp.Op2.slot)) { + return false + } + return npcAttackValidateHooks.all { hook -> + hook.validate(source, target) !is NpcAttackValidateResult.Deny + } + } + + private fun Npc.occupiesLightningTile(centre: CoordGrid): Boolean = + bounds().asSequence().any { it.chebyshevDistance(centre) <= LIGHTNING_RADIUS } + + private companion object { + const val SLASH_SOUND: Int = 2500 + const val STAB_SOUND: Int = 2501 + const val PASSIVE_PROC_CHANCE_PERCENT: Int = 20 + const val PASSIVE_LIGHTNING_MAX_HIT_PERCENT: Int = 50 + const val LIGHTNING_DELAY: Int = 1 + const val TARGET_HIT_DELAY: Int = 1 + const val LIGHTNING_RADIUS: Int = 1 + const val SEARCH_ZONE_RADIUS: Int = 1 + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/BlowpipeWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/BlowpipeWeapons.kt new file mode 100644 index 000000000..66d87aacf --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/BlowpipeWeapons.kt @@ -0,0 +1,218 @@ +package org.rsmod.content.other.special.weapons.ranged + +import dev.openrune.rscm.RSCM +import dev.openrune.rscm.RSCMType +import dev.openrune.types.ItemServerType +import jakarta.inject.Inject +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.commons.ranged.RangedAmmunition +import org.rsmod.api.config.refs.params +import org.rsmod.api.mechanics.toxins.NpcPoisonEffectService +import org.rsmod.api.mechanics.toxins.impl.PlayerVenom +import org.rsmod.api.player.hat +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.ranged.BlowpipeAmmo +import org.rsmod.api.player.righthand +import org.rsmod.api.player.worn.EquipmentChecks +import org.rsmod.api.random.GameRandom +import org.rsmod.api.weapons.RangedWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player +import org.rsmod.game.type.getInvObj + +/** + * Toxic/Rosewood blowpipe normal attacks. Both store their own darts (and, for the toxic family, + * Zulrah's scales) packed inside the weapon item itself via [BlowpipeAmmo] rather than in the + * quiver, so the generic cache-driven ranged fallback (which only knows about quiver ammo) can + * never fire them - this is the piece that was missing, not anything about the special attacks + * themselves (those were already correctly written, just blocked on [BlowpipeAmmo] not existing). + * + * Wiki-verified: Toxic blowpipe attacks have a 25% chance to envenom (100% against an NPC target + * while wearing a serpentine helm); firing has a 1/3 chance to *not* consume a scale. Rosewood + * blowpipe has neither scales nor any poison/venom chance. + */ +class BlowpipeWeapons +@Inject +constructor( + private val random: GameRandom, + private val poisons: NpcPoisonEffectService, +) : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + val toxic = ToxicBlowpipe(manager, random, poisons) + register("obj.toxic_blowpipe_loaded", toxic) + register("obj.toxic_blowpipe_loaded_ornament", toxic) + + val rosewood = RosewoodBlowpipe(manager, random) + register("obj.rosewood_blowpipe", rosewood) + } + + private class ToxicBlowpipe( + private val manager: WeaponAttackManager, + private val random: GameRandom, + private val poisons: NpcPoisonEffectService, + ) : RangedWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = fire(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = fire(target, attack) + + private fun ProtectedAccess.fire(target: PathingEntity, attack: CombatAttack.Ranged): Boolean { + val weaponType = getInvObj(attack.weapon) + val equipped = player.righthand + val dart = BlowpipeAmmo.loadedDart(equipped) + if (dart == null || !BlowpipeAmmo.canUseLoadedDart(equipped)) { + manager.stopCombat(this) + mes("Your toxic blowpipe has no usable darts loaded.") + return true + } + if (!BlowpipeAmmo.hasScales(equipped, 1)) { + manager.stopCombat(this) + mes("Your toxic blowpipe has run out of scales.") + return true + } + + val travelSpotanim = dart.type.paramOrNull(params.proj_travel) + if (travelSpotanim == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return true + } + + val consumeDart = !RangedAmmunition.conserveAmmo(player, random) + val consumeScale = !random.randomBoolean(SCALE_CONSERVE_ROLL) + + manager.playWeaponFx(this, attack) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + + val projectile = + manager.spawnProjectile( + this, + target, + RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id), + weaponType.projectileType(), + ) + val (serverDelay, clientDelay) = projectile.durations + + val damage = manager.rollRangedDamage(this, target, attack) + val guaranteedNpcVenom = target is Npc && EquipmentChecks.isSerpentineHelm(player.hat) + val venom = + damage > 0 && (guaranteedNpcVenom || random.randomBoolean(VENOM_ROLL_DENOMINATOR)) + + val consumption = + BlowpipeAmmo.consume( + player = player, + darts = if (consumeDart) 1 else 0, + scales = if (consumeScale) 1 else 0, + ) + if (consumption == null) { + manager.stopCombat(this) + return true + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit(this, target, dart.type, damage, clientDelay, serverDelay) + if (venom) { + when (target) { + is Npc -> poisons.applyVenom(player, target) + is Player -> PlayerVenom.tryVenom(target) + } + } + + if (!consumption.canFire) { + if (consumption.dartsLeft == 0) { + mes("Your toxic blowpipe has run out of darts.") + } else { + mes("Your toxic blowpipe has run out of scales.") + } + manager.stopCombat(this) + } else { + manager.continueCombat(this, target) + } + return true + } + } + + private class RosewoodBlowpipe( + private val manager: WeaponAttackManager, + private val random: GameRandom, + ) : RangedWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean = fire(target, attack) + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean = fire(target, attack) + + private fun ProtectedAccess.fire(target: PathingEntity, attack: CombatAttack.Ranged): Boolean { + val weaponType = getInvObj(attack.weapon) + val equipped = player.righthand + val dart = BlowpipeAmmo.loadedDart(equipped) + if (dart == null || !BlowpipeAmmo.canUseLoadedDart(equipped)) { + manager.stopCombat(this) + mes("Your rosewood blowpipe has no usable darts loaded.") + return true + } + + val travelSpotanim = dart.type.paramOrNull(params.proj_travel) + if (travelSpotanim == null) { + manager.stopCombat(this) + mes("You are unable to fire your ammunition.") + return true + } + + val consumeDart = !RangedAmmunition.conserveAmmo(player, random) + + manager.playWeaponFx(this, attack) + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + + val projectile = + manager.spawnProjectile( + this, + target, + RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id), + weaponType.projectileType(), + ) + val (serverDelay, clientDelay) = projectile.durations + + val damage = manager.rollRangedDamage(this, target, attack) + + val consumption = BlowpipeAmmo.consume(player, darts = if (consumeDart) 1 else 0) + if (consumption == null) { + manager.stopCombat(this) + return true + } + + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit(this, target, dart.type, damage, clientDelay, serverDelay) + + if (consumption.becameEmpty) { + mes("Your rosewood blowpipe has run out of darts.") + manager.stopCombat(this) + } else { + manager.continueCombat(this, target) + } + return true + } + } + + private companion object { + const val SCALE_CONSERVE_ROLL: Int = 3 + const val VENOM_ROLL_DENOMINATOR: Int = 4 + } +} + +private fun ItemServerType.projectileType(): String = + paramOrNull(params.proj_type)?.let { RSCM.getReverseMapping(RSCMType.PROJANIM, it.id) } + ?: "projanim.thrown" diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/CrawsBowWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/CrawsBowWeapons.kt new file mode 100644 index 000000000..a932312b3 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/CrawsBowWeapons.kt @@ -0,0 +1,130 @@ +package org.rsmod.content.other.special.weapons.ranged + +import jakarta.inject.Inject +import org.rsmod.api.area.checker.AreaChecker +import org.rsmod.api.area.checker.isInWilderness +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.CombatChargeManager +import org.rsmod.api.config.constants +import org.rsmod.api.obj.charges.ObjChargeManager.Companion.isFailure +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.weapons.RangedWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Wiki: "it does provide its own ammo, rolling damage against an NPC's standard ranged defence... + * It also has a powerful passive effect within the Wilderness" (+50% ranged accuracy and damage + * against NPCs, same multiplier as the Webweaver bow it upgrades into). No special attack. Same + * `chargebow` category issue as the Webweaver bow - unregistered here means the base combat + * scripts refuse to fire it at all. + * + * Uses 1 revenant ether charge per shot via [CombatChargeManager], mirroring + * [WebweaverBowWeapons] exactly. + */ +class CrawsBowWeapons +@Inject +constructor( + private val charges: CombatChargeManager, + private val areaChecker: AreaChecker, +) : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + register("obj.wild_cave_bow_uncharged", UnchargedCrawsBow(manager)) + register("obj.wild_cave_bow_charged", CrawsBow(manager, charges, areaChecker)) + } + + private class CrawsBow( + private val manager: WeaponAttackManager, + private val charges: CombatChargeManager, + private val areaChecker: AreaChecker, + ) : RangedWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean { + shoot(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean { + shoot(target, attack) + return true + } + + private fun ProtectedAccess.shoot(target: PathingEntity, attack: CombatAttack.Ranged) { + val chargeResult = charges.attemptDetractWeapon(player, ETHER_VAROBJ) + if (chargeResult.isFailure()) { + manager.stopCombat(this) + mes("Your Craw's bow has run out of charges.") + return + } + + manager.playWeaponFx(this, attack) + spotanim(LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) + + val projectile = + manager.spawnProjectile(this, target, ARROW_TRAVEL_SPOTANIM, ARROW_PROJANIM) + val (serverDelay, clientDelay) = projectile.durations + + // Wiki: the Wilderness passive boosts ranged accuracy/damage against NPCs only. + val wildernessBoost = target is Npc && player.coords.isInWilderness(areaChecker) + val multiplier = if (wildernessBoost) WILDERNESS_MULTIPLIER else 1.0 + val damage = + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = multiplier, + maxHitMultiplier = multiplier, + ) + + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit(this, target, ammo = null, damage, clientDelay, serverDelay) + + if (chargeResult.fullyUncharged) { + manager.stopCombat(this) + mes("Your Craw's bow has run out of charges.") + return + } + manager.continueCombat(this, target) + } + } + + private class UnchargedCrawsBow(private val manager: WeaponAttackManager) : RangedWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean { + terminateAttack() + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean { + terminateAttack() + return true + } + + private fun ProtectedAccess.terminateAttack() { + mes("Your Craw's bow needs to be charged with revenant ether first.") + manager.stopCombat(this) + } + } + + private companion object { + const val ETHER_VAROBJ: String = "varobj.charges_16383" + const val WILDERNESS_MULTIPLIER: Double = 1.5 + const val LAUNCH_SPOTANIM: String = "spotanim.wild_cave_bow_arrow_launch" + const val ARROW_TRAVEL_SPOTANIM: String = "spotanim.wild_cave_bow_arrow_travel" + const val ARROW_PROJANIM: String = "projanim.arrow" + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/WebweaverBowWeapons.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/WebweaverBowWeapons.kt new file mode 100644 index 000000000..bf245b619 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/WebweaverBowWeapons.kt @@ -0,0 +1,131 @@ +package org.rsmod.content.other.special.weapons.ranged + +import jakarta.inject.Inject +import org.rsmod.api.area.checker.AreaChecker +import org.rsmod.api.area.checker.isInWilderness +import org.rsmod.api.combat.commons.CombatAttack +import org.rsmod.api.combat.manager.CombatChargeManager +import org.rsmod.api.config.constants +import org.rsmod.api.obj.charges.ObjChargeManager.Companion.isFailure +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.weapons.RangedWeapon +import org.rsmod.api.weapons.WeaponAttackManager +import org.rsmod.api.weapons.WeaponMap +import org.rsmod.api.weapons.WeaponRepository +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.PathingEntity +import org.rsmod.game.entity.Player + +/** + * Wiki: "identical in function to Craw's bow (i.e. generates its own ammo, requires revenant + * ether to fire, passive effect in the Wilderness)." Was entirely unregistered for normal + * attacks - the item's real cache `category` is `chargebow`, which the base combat scripts + * explicitly refuse to fire unless a `WeaponMap` is registered for it (see the "refuses to fire" + * message in `PvNCombat`/`PvPCombat`). + * + * Uses 1 revenant ether charge per shot via the same [CombatChargeManager] primitive the special + * attack now also uses. Wiki: "an additional 50% ranged accuracy and damage boost" applies only + * against NPCs while in the Wilderness. + */ +class WebweaverBowWeapons +@Inject +constructor( + private val charges: CombatChargeManager, + private val areaChecker: AreaChecker, +) : WeaponMap { + override fun WeaponRepository.register(manager: WeaponAttackManager) { + register("obj.wild_cave_webweaver_uncharged", UnchargedWebweaverBow(manager)) + register("obj.wild_cave_webweaver_charged", WebweaverBow(manager, charges, areaChecker)) + } + + private class WebweaverBow( + private val manager: WeaponAttackManager, + private val charges: CombatChargeManager, + private val areaChecker: AreaChecker, + ) : RangedWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean { + shoot(target, attack) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean { + shoot(target, attack) + return true + } + + private fun ProtectedAccess.shoot(target: PathingEntity, attack: CombatAttack.Ranged) { + val chargeResult = charges.attemptDetractWeapon(player, ETHER_VAROBJ) + if (chargeResult.isFailure()) { + manager.stopCombat(this) + mes("Your Webweaver bow has run out of charges.") + return + } + + manager.playWeaponFx(this, attack) + spotanim(LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) + + val projectile = + manager.spawnProjectile(this, target, ARROW_TRAVEL_SPOTANIM, ARROW_PROJANIM) + val (serverDelay, clientDelay) = projectile.durations + + // Wiki: the Wilderness passive boosts ranged accuracy/damage against NPCs only. + val wildernessBoost = target is Npc && player.coords.isInWilderness(areaChecker) + val multiplier = if (wildernessBoost) WILDERNESS_MULTIPLIER else 1.0 + val damage = + manager.rollRangedDamage( + source = this, + target = target, + attack = attack, + accuracyMultiplier = multiplier, + maxHitMultiplier = multiplier, + ) + + manager.giveCombatXp(this, target, attack, damage) + manager.queueRangedHit(this, target, ammo = null, damage, clientDelay, serverDelay) + + if (chargeResult.fullyUncharged) { + manager.stopCombat(this) + mes("Your Webweaver bow has run out of charges.") + return + } + manager.continueCombat(this, target) + } + } + + private class UnchargedWebweaverBow(private val manager: WeaponAttackManager) : RangedWeapon { + override suspend fun ProtectedAccess.attack( + target: Npc, + attack: CombatAttack.Ranged, + ): Boolean { + terminateAttack() + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Ranged, + ): Boolean { + terminateAttack() + return true + } + + private fun ProtectedAccess.terminateAttack() { + mes("Your Webweaver bow needs to be charged with revenant ether first.") + manager.stopCombat(this) + } + } + + private companion object { + const val ETHER_VAROBJ: String = "varobj.charges_16383" + const val WILDERNESS_MULTIPLIER: Double = 1.5 + const val LAUNCH_SPOTANIM: String = "spotanim.wild_cave_bow_arrow_launch02" + const val ARROW_TRAVEL_SPOTANIM: String = "spotanim.wild_cave_bow_arrow_travel02" + const val ARROW_PROJANIM: String = "projanim.arrow" + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/TrailblazerToolWieldFix.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/TrailblazerToolWieldFix.kt new file mode 100644 index 000000000..5ed8273d8 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/TrailblazerToolWieldFix.kt @@ -0,0 +1,57 @@ +package org.rsmod.content.other.special.weapons.scripts + +import jakarta.inject.Inject +import org.rsmod.api.player.interact.HeldInteractions +import org.rsmod.api.player.output.mes +import org.rsmod.api.player.worn.HeldEquipResult +import org.rsmod.api.script.onOpHeld1 +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * The real cache puts every Trailblazer/Trailblazer Reloaded tool's "Wield" option at `iop1` + * (confirmed via a live cache dump), not `iop2` like most other weapons. This engine's default + * equip handling only fires on op2 ([HeldInteractions.opHeld2]/`onOpHeld2`'s own doc: "replaces the + * default wield/wear op handling"). Nothing was registered for op1 on these items, so clicking + * "Wield" routed to the generic op1 fallback, which does nothing - the item could never be worn. + * + * Fixed by manually delegating op1 to [HeldInteractions.equip], the same bypass entry point the + * engine's own op2 handler uses internally. + */ +class TrailblazerToolWieldFix @Inject constructor(private val heldInteractions: HeldInteractions) : + PluginScript() { + override fun ScriptContext.startup() { + for (item in WIELD_AT_OP1) { + onOpHeld1(item) { + val result = heldInteractions.equip(this, it.inventory, it.slot) + if (result is HeldEquipResult.Fail) { + result.messages.forEach(::mes) + } + } + } + } + + private companion object { + val WIELD_AT_OP1 = + listOf( + "obj.trailblazer_axe", + "obj.trailblazer_axe_empty", + "obj.trailblazer_axe_no_infernal", + "obj.trailblazer_harpoon", + "obj.trailblazer_harpoon_empty", + "obj.trailblazer_harpoon_no_infernal", + "obj.trailblazer_pickaxe", + "obj.trailblazer_pickaxe_empty", + "obj.trailblazer_pickaxe_no_infernal", + "obj.trailblazer_reloaded_axe", + "obj.trailblazer_reloaded_axe_empty", + "obj.trailblazer_reloaded_axe_no_infernal", + "obj.trailblazer_reloaded_harpoon", + "obj.trailblazer_reloaded_harpoon_empty", + "obj.trailblazer_reloaded_harpoon_no_infernal", + "obj.trailblazer_reloaded_pickaxe", + "obj.trailblazer_reloaded_pickaxe_empty", + "obj.trailblazer_reloaded_pickaxe_no_infernal", + ) + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/BlowpipeCharging.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/BlowpipeCharging.kt new file mode 100644 index 000000000..d77f1d738 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/BlowpipeCharging.kt @@ -0,0 +1,201 @@ +package org.rsmod.content.other.special.weapons.scripts.charge + +import jakarta.inject.Inject +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.ranged.BlowpipeAmmo +import org.rsmod.api.player.righthand +import org.rsmod.api.repo.obj.ObjRepository +import org.rsmod.api.script.onOpHeld3 +import org.rsmod.api.script.onOpHeld4 +import org.rsmod.api.script.onOpHeld5 +import org.rsmod.api.script.onOpHeldU +import org.rsmod.api.script.onOpWorn2 +import org.rsmod.api.utils.format.formatAmount +import org.rsmod.game.inv.InvObj +import org.rsmod.game.inv.Inventory +import org.rsmod.game.type.getInvObj +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Loading darts and (for the toxic family) Zulrah's scales into a blowpipe, plus the real cache + * options confirmed via a live cache dump: `Check`/`Unload`/`Uncharge`. Both the empty and + * already-loaded cache variants accept more ammo - a blowpipe may only hold one dart type at a + * time, enforced by [BlowpipeAmmo.storeDarts] itself. + * + * Real op layout, confirmed cache-side (`iop2=Wield, iop3=Check, iop4=Unload, iop5=Uncharge` on + * the toxic loaded variants; `iop2=Wield, iop3=Check, iop5=Unload` on rosewood, which has no + * scales and so no Uncharge). `Uncharge`/`Unload` deliberately replace the item's default Drop op + * at position 5 - that's real Jagex behavior on this weapon, not something introduced here. + * + * `Check` while worn: the real cache lists `param.wear_op1=Check`, but in this engine op1 while + * worn is hardcoded "Remove" regardless of what `wear_op1` claims - registering `onOpWorn1` made + * the Remove button silently run Check instead of unequipping. Used `onOpWorn2` instead (the same + * slot `TumekensShadowCharging` already uses for its own worn-Check), which is a genuinely free + * slot in this engine and doesn't touch Remove. + * + * "Use scales on blowpipe" used to silently do nothing - see `FinishedPotionsEvents.heldUExclude` + * for why (a Herblore catch-all was intercepting scales before dispatch reached this file). + */ +class BlowpipeCharging @Inject constructor(private val objRepo: ObjRepository) : PluginScript() { + override fun ScriptContext.startup() { + val dartLoadablePipes = BlowpipeAmmo.toxicBlowpipeAliases() + ROSEWOOD_ALIASES + for (pipe in dartLoadablePipes) { + for (dart in BlowpipeAmmo.toxicDartAliases()) { + onOpHeldU(pipe, dart) { loadDarts(inv, it.firstSlot, it.secondSlot, dart) } + } + } + + for (pipe in BlowpipeAmmo.toxicBlowpipeAliases()) { + onOpHeldU(pipe, SCALE_ITEM) { loadScales(inv, it.firstSlot) } + } + + for (pipe in BlowpipeAmmo.toxicLoadedBlowpipeAliases()) { + onOpHeld3(pipe) { checkAmmo(inv[it.slot]) } + onOpWorn2(pipe) { checkAmmo(player.righthand) } + } + onOpHeld3(ROSEWOOD_LOADED) { checkAmmo(inv[it.slot]) } + onOpWorn2(ROSEWOOD_LOADED) { checkAmmo(player.righthand) } + + // Toxic: Unload (darts only, keeps scales) is op4, Uncharge (darts + scales) is op5. + onOpHeld4("obj.toxic_blowpipe_loaded") { unloadDarts(inv, it.slot) } + onOpHeld4("obj.toxic_blowpipe_loaded_ornament") { unloadDarts(inv, it.slot) } + onOpHeld5("obj.toxic_blowpipe_loaded") { uncharge(inv, it.slot) } + onOpHeld5("obj.toxic_blowpipe_loaded_ornament") { uncharge(inv, it.slot) } + + // Rosewood has no scales, so its only removal op ("Unload") sits at op5 instead. + onOpHeld5(ROSEWOOD_LOADED) { unloadDarts(inv, it.slot) } + } + + private suspend fun ProtectedAccess.loadDarts( + inventory: Inventory, + pipeSlot: Int, + dartSlot: Int, + dartAlias: String, + ) { + val dartObj = inventory[dartSlot] ?: return + val dartType = getInvObj(dartObj) + val available = dartObj.count + + val question = "How many darts do you want to load? (up to ${available.formatAmount})" + val requested = countDialog(question).coerceIn(1, available) + + val added = BlowpipeAmmo.storeDarts(inventory, pipeSlot, dartType, requested) + if (added <= 0) { + mes("You can't load that many darts, or that dart type doesn't match what's already loaded.") + return + } + + val removed = invDel(inv, dartAlias, added) + if (removed.failure) { + return + } + mes("You load ${added.formatAmount} darts into your blowpipe.") + } + + private suspend fun ProtectedAccess.loadScales(inventory: Inventory, pipeSlot: Int) { + val available = invTotal(inv, SCALE_ITEM) + if (available <= 0) { + mes("You don't have any Zulrah's scales.") + return + } + + val question = "How many scales do you want to load? (up to ${available.formatAmount})" + val requested = countDialog(question).coerceIn(1, available) + + val added = BlowpipeAmmo.storeScales(inventory, pipeSlot, requested) + if (added <= 0) { + mes("Your blowpipe is already full of scales.") + return + } + + val removed = invDel(inv, SCALE_ITEM, added) + if (removed.failure) { + return + } + mes("You load ${added.formatAmount} Zulrah's scales into your blowpipe.") + } + + private fun ProtectedAccess.checkAmmo(obj: InvObj?) { + val dartCount = BlowpipeAmmo.darts(obj) + if (dartCount <= 0) { + mes("Your blowpipe has no darts loaded.") + return + } + val dart = BlowpipeAmmo.storedDart(obj) + val dartName = dart?.type?.name?.lowercase() ?: "darts" + if (BlowpipeAmmo.isToxic(obj)) { + val scaleCount = BlowpipeAmmo.scales(obj) + mes( + "Your blowpipe currently has ${dartCount.formatAmount} $dartName and " + + "${scaleCount.formatAmount} charges of scales." + ) + } else { + mes("Your blowpipe currently has ${dartCount.formatAmount} $dartName loaded.") + } + } + + private suspend fun ProtectedAccess.unloadDarts(inventory: Inventory, slot: Int) { + val obj = inventory[slot] ?: return + val count = BlowpipeAmmo.darts(obj) + if (count <= 0) { + mes("Your blowpipe has no darts loaded.") + return + } + if (inv.freeSpace() < 1) { + mes("You don't have enough inventory space to unload your darts.") + return + } + + val unloaded = BlowpipeAmmo.unloadDarts(inventory, slot) ?: return + val dart = unloaded.dart + if (dart != null && unloaded.count > 0) { + invAddOrDropType(objRepo, dart.type, unloaded.count) + } + mes("You remove ${unloaded.count.formatAmount} darts from your blowpipe.") + } + + private suspend fun ProtectedAccess.uncharge(inventory: Inventory, slot: Int) { + val obj = inventory[slot] ?: return + val dartCount = BlowpipeAmmo.darts(obj) + val scaleCount = BlowpipeAmmo.scales(obj) + if (dartCount <= 0 && scaleCount <= 0) { + mes("Your blowpipe is already empty.") + return + } + + val spaceNeeded = (if (dartCount > 0) 1 else 0) + (if (scaleCount > 0) 1 else 0) + if (inv.freeSpace() < spaceNeeded) { + mes("You don't have enough inventory space to uncharge your blowpipe.") + return + } + + val confirmed = + choice2( + "Proceed.", + true, + "Cancel.", + false, + title = "Uncharge all darts and scales from your blowpipe?", + ) + if (!confirmed) { + return + } + + val contents = BlowpipeAmmo.uncharge(inventory, slot) ?: return + val dart = contents.dart + if (dart != null && contents.dartCount > 0) { + invAddOrDropType(objRepo, dart.type, contents.dartCount) + } + if (contents.scaleCount > 0) { + invAddOrDrop(objRepo, SCALE_ITEM, contents.scaleCount) + } + mes("You uncharge your blowpipe.") + } + + private companion object { + const val SCALE_ITEM: String = "obj.snakeboss_scale" + const val ROSEWOOD_LOADED: String = "obj.rosewood_blowpipe" + val ROSEWOOD_ALIASES = listOf(ROSEWOOD_LOADED, "obj.rosewood_blowpipe_empty") + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/CrawsBowCharging.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/CrawsBowCharging.kt new file mode 100644 index 000000000..79ebcc92b --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/CrawsBowCharging.kt @@ -0,0 +1,146 @@ +package org.rsmod.content.other.special.weapons.scripts.charge + +import jakarta.inject.Inject +import org.rsmod.api.obj.charges.ObjChargeManager +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.repo.obj.ObjRepository +import org.rsmod.api.script.onOpHeld3 +import org.rsmod.api.script.onOpHeld5 +import org.rsmod.api.script.onOpHeldU +import org.rsmod.api.script.onOpWorn2 +import org.rsmod.api.utils.format.formatAmount +import org.rsmod.game.inv.InvObj +import org.rsmod.game.inv.Inventory +import org.rsmod.game.inv.isType +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Wiki: "the bow must first be activated with 1,000 revenant ether... The 1,000 ether used to + * activate the bow does not count towards the ammo usage of the bow, thus additional ether must + * be added (up to 16,000)." Same activation cost, max ammo, and revenant ether item as the + * Webweaver bow it can be upgraded into - mirrors [WebweaverBowCharging] exactly. + * + * Real op layout confirmed cache-side: `iop3=Check, iop5=Uncharge` on the charged variant, no + * separate partial "Unload" (same all-or-nothing Uncharge as Webweaver bow). Check is on + * `onOpWorn2` rather than `onOpWorn1` for the same reason as the other charge weapons in this + * package: `onOpWorn1` while worn is hardcoded "Remove" in this engine regardless of what the + * real client's `wear_op1` claims. + */ +class CrawsBowCharging +@Inject +constructor( + private val charges: ObjChargeManager, + private val objRepo: ObjRepository, +) : PluginScript() { + override fun ScriptContext.startup() { + onOpHeldU("obj.wild_cave_bow_uncharged", "obj.wild_cave_shard") { + activate(inv, it.firstSlot) + } + onOpHeldU("obj.wild_cave_bow_charged", "obj.wild_cave_shard") { topUp(inv, it.firstSlot) } + + onOpHeld3(CHARGED) { checkCharges(inv[it.slot]) } + onOpWorn2(CHARGED) { checkCharges(player.righthand) } + onOpHeld5(CHARGED) { uncharge(inv, it.slot) } + } + + private suspend fun ProtectedAccess.activate(inventory: Inventory, invSlot: Int) { + val available = invTotal(inv, ETHER_ITEM) + if (available < ACTIVATION_COST) { + mes( + "You need at least ${ACTIVATION_COST.formatAmount} revenant ether to " + + "activate this bow." + ) + return + } + + val question = + "How much revenant ether do you want to use? (minimum " + + "${ACTIVATION_COST.formatAmount}, up to ${available.formatAmount})" + val requested = countDialog(question).coerceIn(ACTIVATION_COST, available) + + val removed = invDel(inv, ETHER_ITEM, requested) + if (removed.failure) { + return + } + + // Paranoid check: Should always be the case. + check(inventory[invSlot].isType("obj.wild_cave_bow_uncharged")) + + val startingCharges = requested - ACTIVATION_COST + charges.addCharges(inventory, invSlot, startingCharges, ETHER_VAROBJ, MAX_CHARGES) + + val suffix = if (startingCharges > 0) " The rest is loaded as ammo." else "" + mes("You activate your Craw's bow, using ${ACTIVATION_COST.formatAmount} revenant ether.$suffix") + } + + private suspend fun ProtectedAccess.topUp(inventory: Inventory, invSlot: Int) { + val available = invTotal(inv, ETHER_ITEM) + if (available <= 0) { + mes("You don't have any revenant ether.") + return + } + + val current = charges.getCharges(inventory[invSlot], ETHER_VAROBJ) + if (current >= MAX_CHARGES) { + mes("Your Craw's bow is fully charged.") + return + } + + val maxAdd = MAX_CHARGES - current + val cap = minOf(available, maxAdd) + val question = "How much revenant ether do you want to add? (up to ${cap.formatAmount})" + val requested = countDialog(question).coerceIn(1, cap) + + val removed = invDel(inv, ETHER_ITEM, requested) + if (removed.failure) { + return + } + + charges.addCharges(inventory, invSlot, requested, ETHER_VAROBJ, MAX_CHARGES) + mes("You add ${requested.formatAmount} revenant ether to your Craw's bow.") + } + + private fun ProtectedAccess.checkCharges(obj: InvObj?) { + val remaining = charges.getCharges(obj, ETHER_VAROBJ) + mes("Your Craw's bow has ${remaining.formatAmount} charges of revenant ether left.") + } + + private suspend fun ProtectedAccess.uncharge(inventory: Inventory, invSlot: Int) { + val current = charges.getCharges(inventory[invSlot], ETHER_VAROBJ) + if (current == 0) { + mes("Your Craw's bow has no charges to uncharge.") + return + } + if (inv.freeSpace() < 1) { + mes("You don't have enough inventory space to uncharge your Craw's bow.") + return + } + + val confirmed = + choice2( + "Proceed.", + true, + "Cancel.", + false, + title = "Uncharge all revenant ether from your Craw's bow?", + ) + if (!confirmed) { + return + } + + val removed = charges.removeAllCharges(inventory, invSlot, ETHER_VAROBJ) + check(removed > 0) + invAddOrDrop(objRepo, ETHER_ITEM, removed) + mes("You uncharge your Craw's bow, regaining ${removed.formatAmount} revenant ether.") + } + + private companion object { + const val CHARGED: String = "obj.wild_cave_bow_charged" + const val ETHER_ITEM: String = "obj.wild_cave_shard" + const val ETHER_VAROBJ: String = "varobj.charges_16383" + const val ACTIVATION_COST: Int = 1000 + const val MAX_CHARGES: Int = 16000 + } +} diff --git a/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/WebweaverBowCharging.kt b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/WebweaverBowCharging.kt new file mode 100644 index 000000000..4f88a14d3 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/WebweaverBowCharging.kt @@ -0,0 +1,153 @@ +package org.rsmod.content.other.special.weapons.scripts.charge + +import jakarta.inject.Inject +import org.rsmod.api.obj.charges.ObjChargeManager +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.righthand +import org.rsmod.api.repo.obj.ObjRepository +import org.rsmod.api.script.onOpHeld3 +import org.rsmod.api.script.onOpHeld5 +import org.rsmod.api.script.onOpHeldU +import org.rsmod.api.script.onOpWorn2 +import org.rsmod.api.utils.format.formatAmount +import org.rsmod.game.inv.InvObj +import org.rsmod.game.inv.Inventory +import org.rsmod.game.inv.isType +import org.rsmod.plugin.scripts.PluginScript +import org.rsmod.plugin.scripts.ScriptContext + +/** + * Wiki: "the bow must first be activated with 1,000 revenant ether... The 1,000 ether used to + * activate the bow does not count towards the ammo usage of the bow, thus additional ether must + * be added (up to 16,000) to fire it." The real item is `obj.wild_cave_shard` in this cache, not + * a "revenant_ether"-named alias. + * + * Real op layout confirmed via a live cache dump: `iop3=Check, iop5=Uncharge` on the charged + * variant - no separate partial "Unload" exists on this item at all, only an all-or-nothing + * Uncharge (matching the activated-with-ObjChargeManager charge model - there's one pooled charge + * count, not a separate dart/scale-style breakdown). Check is on `onOpWorn2` rather than `onOpWorn1` + * despite the real client showing it at `wear_op1` - `onOpWorn1` replaces this engine's own + * "Remove" handling regardless of what the real client's op1 label says (confirmed the hard way on + * the toxic blowpipe's own Check option), so `onOpWorn2` is the safe slot instead. + */ +class WebweaverBowCharging +@Inject +constructor( + private val charges: ObjChargeManager, + private val objRepo: ObjRepository, +) : PluginScript() { + override fun ScriptContext.startup() { + onOpHeldU("obj.wild_cave_webweaver_uncharged", "obj.wild_cave_shard") { + activate(inv, it.firstSlot) + } + onOpHeldU("obj.wild_cave_webweaver_charged", "obj.wild_cave_shard") { + topUp(inv, it.firstSlot) + } + + onOpHeld3(CHARGED) { checkCharges(inv[it.slot]) } + onOpWorn2(CHARGED) { checkCharges(player.righthand) } + onOpHeld5(CHARGED) { uncharge(inv, it.slot) } + } + + private suspend fun ProtectedAccess.activate(inventory: Inventory, invSlot: Int) { + val available = invTotal(inv, ETHER_ITEM) + if (available < ACTIVATION_COST) { + mes( + "You need at least ${ACTIVATION_COST.formatAmount} revenant ether to " + + "activate this bow." + ) + return + } + + val question = + "How much revenant ether do you want to use? (minimum " + + "${ACTIVATION_COST.formatAmount}, up to ${available.formatAmount})" + val requested = countDialog(question).coerceIn(ACTIVATION_COST, available) + + val removed = invDel(inv, ETHER_ITEM, requested) + if (removed.failure) { + return + } + + // Paranoid check: Should always be the case. + check(inventory[invSlot].isType("obj.wild_cave_webweaver_uncharged")) + + val startingCharges = requested - ACTIVATION_COST + charges.addCharges(inventory, invSlot, startingCharges, ETHER_VAROBJ, MAX_CHARGES) + + val suffix = if (startingCharges > 0) " The rest is loaded as ammo." else "" + mes( + "You activate your Webweaver bow, using ${ACTIVATION_COST.formatAmount} " + + "revenant ether.$suffix" + ) + } + + private suspend fun ProtectedAccess.topUp(inventory: Inventory, invSlot: Int) { + val available = invTotal(inv, ETHER_ITEM) + if (available <= 0) { + mes("You don't have any revenant ether.") + return + } + + val current = charges.getCharges(inventory[invSlot], ETHER_VAROBJ) + if (current >= MAX_CHARGES) { + mes("Your Webweaver bow is fully charged.") + return + } + + val maxAdd = MAX_CHARGES - current + val cap = minOf(available, maxAdd) + val question = "How much revenant ether do you want to add? (up to ${cap.formatAmount})" + val requested = countDialog(question).coerceIn(1, cap) + + val removed = invDel(inv, ETHER_ITEM, requested) + if (removed.failure) { + return + } + + charges.addCharges(inventory, invSlot, requested, ETHER_VAROBJ, MAX_CHARGES) + mes("You add ${requested.formatAmount} revenant ether to your Webweaver bow.") + } + + private fun ProtectedAccess.checkCharges(obj: InvObj?) { + val remaining = charges.getCharges(obj, ETHER_VAROBJ) + mes("Your Webweaver bow has ${remaining.formatAmount} charges of revenant ether left.") + } + + private suspend fun ProtectedAccess.uncharge(inventory: Inventory, invSlot: Int) { + val current = charges.getCharges(inventory[invSlot], ETHER_VAROBJ) + if (current == 0) { + mes("Your Webweaver bow has no charges to uncharge.") + return + } + if (inv.freeSpace() < 1) { + mes("You don't have enough inventory space to uncharge your Webweaver bow.") + return + } + + val confirmed = + choice2( + "Proceed.", + true, + "Cancel.", + false, + title = "Uncharge all revenant ether from your Webweaver bow?", + ) + if (!confirmed) { + return + } + + val removed = charges.removeAllCharges(inventory, invSlot, ETHER_VAROBJ) + check(removed > 0) + invAddOrDrop(objRepo, ETHER_ITEM, removed) + mes("You uncharge your Webweaver bow, regaining ${removed.formatAmount} revenant ether.") + } + + private companion object { + const val CHARGED: String = "obj.wild_cave_webweaver_charged" + const val ETHER_ITEM: String = "obj.wild_cave_shard" + const val ETHER_VAROBJ: String = "varobj.charges_16383" + const val ACTIVATION_COST: Int = 1000 + const val MAX_CHARGES: Int = 16000 + } +} diff --git a/content/skills/herblore/src/main/kotlin/org/rsmod/content/skills/herblore/FinishedPotionsEvents.kt b/content/skills/herblore/src/main/kotlin/org/rsmod/content/skills/herblore/FinishedPotionsEvents.kt index 57d09cabe..d003b82d8 100644 --- a/content/skills/herblore/src/main/kotlin/org/rsmod/content/skills/herblore/FinishedPotionsEvents.kt +++ b/content/skills/herblore/src/main/kotlin/org/rsmod/content/skills/herblore/FinishedPotionsEvents.kt @@ -234,6 +234,11 @@ class FinishedPotionsEvents @Inject constructor(private val random: GameRandom) add("obj.vial_water") add("obj.pestle_and_mortar") add("obj.swamp_tar") + // Zulrah's scales are also blowpipe/Webweaver bow ammo. This catch-all silently + // no-ops on a non-potion second item, which swallowed "scales on blowpipe" before + // dispatch could try the reversed click order. Extended antivenom+ still works via + // obj.antivenom+3's own catch-all, same as vial_water/pestle/swamp tar below. + add("obj.snakeboss_scale") addAll(HerbloreDefinitions.herbItemNames) HerbloreDefinitions.barbarianMixes.forEach { mix -> add(mix.twoDosePotion.internalName) diff --git a/engine/game/src/main/kotlin/org/rsmod/game/entity/PathingEntity.kt b/engine/game/src/main/kotlin/org/rsmod/game/entity/PathingEntity.kt index 564f3d0a1..38406697b 100644 --- a/engine/game/src/main/kotlin/org/rsmod/game/entity/PathingEntity.kt +++ b/engine/game/src/main/kotlin/org/rsmod/game/entity/PathingEntity.kt @@ -3,16 +3,14 @@ package org.rsmod.game.entity import dev.openrune.ServerCacheManager import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType -import dev.openrune.types.SequenceServerType import dev.openrune.types.WalkTriggerType -import dev.openrune.types.aconverted.SpotanimType import it.unimi.dsi.fastutil.longs.LongArrayList import kotlin.coroutines.startCoroutine import org.rsmod.annotations.InternalApi import org.rsmod.coroutine.GameCoroutine import org.rsmod.coroutine.suspension.GameCoroutineSimpleCompletion -import org.rsmod.game.entity.player.ProtectedAccessLostException import org.rsmod.game.damage.DamageContributions +import org.rsmod.game.entity.player.ProtectedAccessLostException import org.rsmod.game.entity.util.EntityExactMove import org.rsmod.game.entity.util.EntityFaceAngle import org.rsmod.game.entity.util.EntityFaceTarget @@ -105,6 +103,7 @@ public sealed class PathingEntity { public var activeCoroutine: GameCoroutine? = null public val routeDestination: RouteDestination = RouteDestination() public var routeRequest: RouteRequest? = null + // Used for setting temporary move speed for single requests, aka ctrl run mode. public var tempMoveSpeed: MoveSpeed? = null public var moveSpeed: MoveSpeed = MoveSpeed.Stationary @@ -140,6 +139,16 @@ public sealed class PathingEntity { public var walkTrigger: WalkTriggerType? = null private set + /** + * The [Player.uuid] of the most recent player to deal positive final damage to this entity. + * Updated by [recordDamage] alongside [heroPoints]/[damageContributions], so it reflects + * damage from any source (normal attacks and specials alike), not just a single weapon. + * + * Used by specials whose accuracy depends on whether the acting player was the target's last + * damage source (e.g. Bone dagger's Backstab, Dorgeshuun crossbow's Snipe). + */ + public var lastDamagingPlayerUuid: Long? = null + internal var animProtect: Boolean = false public val isSlotAssigned: Boolean @@ -480,6 +489,7 @@ public sealed class PathingEntity { } damageContributions.record(source, damage) heroPoints(source, damage) + lastDamagingPlayerUuid = source.uuid } public fun recordDamage(source: Npc, damage: Int) { diff --git a/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt b/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt index ceaeaede3..7bb6c12a3 100644 --- a/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt +++ b/or-cache/src/main/kotlin/dev/openrune/CacheTools.kt @@ -141,7 +141,33 @@ private fun buildServerCache(packTasks: List, packs: PluginPacks) { MapPackers(), ) - newCacheTool(TaskType.SERVER_CACHE_BUILD, serverOnly + serverTasks).initialize() + // The previous buildCache() pass just wrote a large batch of fresh binary cache files, and + // Windows Defender/Search Indexer (both confirmed running) hold a brief scan/index lock on a + // freshly-written file before releasing it. CacheTool.initialize() below (external + // dev.or2:tools dependency, can't patch directly) then fails to overwrite `SERVER/ + // main_file_cache.idx255` mid-copy from LIVE - FileAlreadyExistsException: "Tried to overwrite + // the destination, but failed to delete it." A single System.gc() didn't help (ruled out - this + // isn't a JVM-held mmap like the MinifyServerCache.kt case), and the lock was confirmed to + // persist with zero JVM processes running at all - it's a genuine external OS-level lock, not + // our own state. Retrying with backoff is the correct mitigation for that, not GC. + retryOnFileLock { newCacheTool(TaskType.SERVER_CACHE_BUILD, serverOnly + serverTasks).initialize() } +} + +private fun retryOnFileLock(attempts: Int = 6, block: () -> Unit) { + repeat(attempts) { attempt -> + try { + block() + return + } catch (e: java.io.IOException) { + // kotlin.io.FileAlreadyExistsException (thrown by the external copyTo call this wraps) + // is a java.io.IOException, not a java.nio.file.FileSystemException - catch the broader + // type deliberately. + if (attempt == attempts - 1) throw e + val delayMs = 500L * (attempt + 1) + logger.warn { "Cache file locked (likely AV/indexer scan) - retrying in ${delayMs}ms: ${e.message}" } + Thread.sleep(delayMs) + } + } } private fun finalizeServerCache() { diff --git a/or-cache/src/main/kotlin/dev/openrune/tools/MinifyServerCache.kt b/or-cache/src/main/kotlin/dev/openrune/tools/MinifyServerCache.kt index 51f0837fc..450296dbe 100644 --- a/or-cache/src/main/kotlin/dev/openrune/tools/MinifyServerCache.kt +++ b/or-cache/src/main/kotlin/dev/openrune/tools/MinifyServerCache.kt @@ -41,6 +41,12 @@ class MinifyServerCache() { temp.mkdirs() cache.rebuild(temp) cache.close() + // `cache.close()` doesn't deterministically release the memory-mapped file handles this + // library opened on `loc`'s own cache files (a known JVM-on-Windows limitation - mapped + // buffers only unmap on GC, with no explicit API to force it before this JDK's Arena/ + // Cleaner APIs). Without this, the copy below intermittently throws + // FileAlreadyExistsException trying to overwrite a file Windows still considers open. + System.gc() temp.copyRecursively(loc, true) temp.deleteRecursively() }