From 331675769cfe4dcaa4039c2ac642f4baf4535e03 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:25:45 -0400 Subject: [PATCH 01/21] feat(special-attacks): convert and verify all melee/ranged/magic special attacks Rebuilt onto current main. This is the special-attacks-only subset of the special-attacks branch: the spawn menu (content/other/spawn, the admin spawn command), and the Lumbridge combat testing dummy have been excluded from this branch - they belong in separate PRs. 73 of 73 special attack weapons now compile, run, and (where they have real math to test) have unit test coverage. Highlights: - New shared subsystems: weapon poison (melee + ranged), Burn/venom/disease status effects, BindEffectService (shove-stun/bind), BlowpipeAmmo charge tracking, PvP area-attack manager, next-cycle ranged special timing, ProtectionPrayerLockout, BypassProtectionPrayerPlayerHitModifier, PowerOfDeathMeleeProtection, defenceMultiplier accuracy threading, PathingEntity.lastDamagingPlayerUuid (hooks into the existing shared recordDamage function - no new pipeline needed). - items.toml: real combat data (weaponCategory, attack animations, ammo categories) filled in for every weapon that had a working special attack but broken/missing normal-attack data - Bounty Hunter corrupted dragon weapons, Deadman mode variants, Trailblazer/Trailblazer Reloaded tools, Dinh's bulwark ornament, Emberlight, dual macuahuitl, Soulreaper axe (o), and more. - Two real engine bugs fixed at the source: ObjChargeManager using the wrong RSCMType for 3 of 4 functions, and this whole Trailblazer tool family's "Wield" sitting at cache op1 (this engine's default equip handling only fires on op2). - content/other/commands/AdminCommands.kt: two small testing commands (::me fills special energy, ::charge adds charges to a wielded weapon) used throughout this work, kept as real debug tooling. - content/other/consumables: a genuine, unrelated Dwarven rock cake fix (self-damage mechanic) and a PlayerInterfaceExtensions setColour bug fix (wrong RGB->5-bit conversion) picked up along the way. Full detail on every weapon and fix is in PROGRESS.md. --- .data/gamevals/projanim.rscm | 1 + .data/raw-cache/server/items.toml | 855 ++++++++++-- .data/raw-cache/server/projectiles.toml | 17 + api/combat/combat-commons/build.gradle.kts | 1 + .../api/combat/commons/BindEffectService.kt | 66 + .../combat/commons/ExpiringDurationMath.kt | 27 + .../api/combat/commons/ShoveStunService.kt | 54 + .../commons/ExpiringDurationMathTest.kt | 34 + .../api/combat/formulas/AccuracyFormulae.kt | 26 +- .../accuracy/melee/PvNMeleeAccuracy.kt | 6 +- .../accuracy/melee/PvPMeleeAccuracy.kt | 6 +- .../maxhit/ranged/EclipseAtlatlMaxHit.kt | 61 + .../formulas/maxhit/ranged/PvNRangedMaxHit.kt | 24 +- .../formulas/maxhit/ranged/PvPRangedMaxHit.kt | 23 +- .../api/combat/manager/PlayerAttackManager.kt | 40 +- .../kotlin/org/rsmod/api/combat/PvNCombat.kt | 24 +- .../kotlin/org/rsmod/api/combat/PvPCombat.kt | 24 +- .../rsmod/api/combat/player/PlayerCommons.kt | 11 +- .../api/combat/player/PvPAreaAttackManager.kt | 52 + .../player/PlayerRunUpdateProcessor.kt | 4 + api/mechanics/toxins/build.gradle.kts | 2 + .../api/mechanics/toxins/BurnEffectService.kt | 138 ++ .../toxins/EclipseAtlatlBurnEffect.kt | 49 + .../toxins/NoxiousHalberdVirulence.kt | 41 + .../toxins/NpcPoisonEffectService.kt | 131 ++ .../mechanics/toxins/SoulreaperStackDecay.kt | 50 + .../mechanics/toxins/WeaponPoisonEffect.kt | 86 ++ .../api/mechanics/toxins/impl/PlayerPoison.kt | 19 +- .../api/mechanics/toxins/BurnStacksTest.kt | 41 + .../mechanics/toxins/NpcPoisonOverrideTest.kt | 58 + .../toxins/SoulreaperStackDecayTest.kt | 33 + .../mechanics/toxins/WeaponPoisonTierTest.kt | 46 + .../rsmod/api/obj/charges/ObjChargeManager.kt | 6 +- ...BypassProtectionPrayerPlayerHitModifier.kt | 24 + .../player/hit/modifier/MorriganHamstring.kt | 27 + .../modifier/PowerOfDeathMeleeProtection.kt | 44 + .../hit/modifier/StandardPlayerHitModifier.kt | 12 + .../hit/modifier/VestaSpearCombatImmunity.kt | 26 + .../player/prayer/ProtectionPrayerLockout.kt | 46 + .../rsmod/api/player/ranged/BlowpipeAmmo.kt | 393 ++++++ .../player/ui/PlayerInterfaceExtensions.kt | 2 + .../rsmod/api/player/worn/EquipmentChecks.kt | 31 + .../specials/NextCycleRangedSpecialTiming.kt | 61 + .../api/specials/SpecialAttackManager.kt | 67 +- .../api/specials/SpecialAttackRegistry.kt | 38 +- .../api/specials/SpecialAttackRepository.kt | 28 +- .../specials/combat/CombatSpecialAttacks.kt | 6 + .../specials/energy/SpecialAttackEnergy.kt | 49 +- .../energy/SpecialAttackEnergyModifier.kt | 50 +- .../specials/weapon/SpecialAttackWeapons.kt | 9 +- .../rsmod/api/weapons/WeaponAttackManager.kt | 21 + .../interfaces/combat/tab/CombatTabScript.kt | 32 +- .../prayer/tab/scripts/PrayerTabScript.kt | 10 + content/other/commands/build.gradle.kts | 1 + .../content/other/commands/AdminCommands.kt | 75 +- .../food/DwarvenRockCakeScript.kt | 65 + .../food/DwarvenRockCakeDamageTest.kt | 41 + content/other/special-attacks/PROGRESS.md | 1176 +++++++++++++++++ .../other/special-attacks/build.gradle.kts | 6 + .../special/attacks/SpecialAttackModule.kt | 139 +- .../boost/DragonBattleaxeSpecialAttack.kt | 54 + .../attacks/boost/StatBoostSpecialAttacks.kt | 30 + .../magic/NightmareStaffSpecialAttacks.kt | 200 +++ .../magic/StaffOfTheDeadSpecialAttack.kt | 68 + .../melee/AbyssalBludgeonSpecialAttack.kt | 81 ++ .../melee/AbyssalDaggerSpecialAttack.kt | 115 ++ .../attacks/melee/AbyssalWhipSpecialAttack.kt | 102 ++ .../melee/AncientGodswordSpecialAttack.kt | 279 ++++ .../attacks/melee/AncientMaceSpecialAttack.kt | 109 ++ .../attacks/melee/AreaMeleeTargetSelector.kt | 130 ++ .../attacks/melee/ArkanBladeSpecialAttack.kt | 96 ++ .../melee/ArmadylGodswordSpecialAttack.kt | 80 ++ .../melee/BarrelchestAnchorSpecialAttack.kt | 118 ++ .../melee/BlueMoonSpearSpecialAttack.kt | 132 ++ .../attacks/melee/BoneDaggerSpecialAttack.kt | 121 ++ .../attacks/melee/BrineSabreSpecialAttack.kt | 146 ++ .../melee/BurningClawsSpecialAttack.kt | 189 +++ .../melee/CrimsonKistenSpecialAttack.kt | 125 ++ .../melee/CrystalHalberdSpecialAttack.kt | 100 ++ .../attacks/melee/DemonbaneSpecialAttacks.kt | 119 ++ .../melee/DinhsBulwarkSpecialAttack.kt | 156 +++ .../attacks/melee/DogswordSpecialAttack.kt | 179 +++ .../melee/Dragon2hSwordSpecialAttack.kt | 92 ++ .../melee/DragonCandleDaggerSpecialAttack.kt | 48 + .../attacks/melee/DragonClawsSpecialAttack.kt | 212 +++ .../melee/DragonDaggerSpecialAttack.kt | 101 ++ .../melee/DragonHalberdSpecialAttack.kt | 100 ++ .../attacks/melee/DragonHastaSpecialAttack.kt | 239 ++++ .../melee/DragonLongswordSpecialAttack.kt | 28 +- .../attacks/melee/DragonMaceSpecialAttack.kt | 63 + .../melee/DragonScimitarSpecialAttack.kt | 70 + .../attacks/melee/DragonSwordSpecialAttack.kt | 71 + .../melee/DualMacuahuitlSpecialAttack.kt | 169 +++ .../attacks/melee/ElderMaulSpecialAttack.kt | 92 ++ .../melee/FangOfTheHoundSpecialAttack.kt | 162 +++ .../melee/GraniteHammerSpecialAttack.kt | 65 + .../attacks/melee/GraniteMaulSpecialAttack.kt | 115 ++ .../attacks/melee/HalberdSpecialVisuals.kt | 22 + .../melee/ImpactMeleeSpecialAttacks.kt | 410 ++++++ .../melee/InfernalTecpatlSpecialAttack.kt | 94 ++ .../melee/NoxiousHalberdSpecialAttack.kt | 61 + .../melee/OsmumtenFangSpecialAttack.kt | 94 ++ .../attacks/melee/RuneClawsSpecialAttack.kt | 65 + .../SaradominBlessedSwordSpecialAttack.kt | 77 ++ .../melee/SaradominSwordSpecialAttack.kt | 100 ++ .../melee/SoulreaperAxeSpecialAttack.kt | 154 +++ .../melee/SoulreaperStackDecayScript.kt | 57 + .../melee/StatiusWarhammerSpecialAttack.kt | 111 ++ .../attacks/melee/SunspearSpecialAttack.kt | 91 ++ .../melee/ThunderKhopeshSpecialAttack.kt | 377 ++++++ .../melee/VampyreFlailSpecialAttack.kt | 213 +++ .../melee/VestaLongswordSpecialAttack.kt | 112 ++ .../attacks/melee/VestaSpearSpecialAttack.kt | 126 ++ .../attacks/melee/VoidwakerSpecialAttack.kt | 89 ++ .../ranged/ArmadylCrossbowSpecialAttack.kt | 402 ++++++ .../attacks/ranged/BallistaSpecialAttack.kt | 128 ++ .../attacks/ranged/DarkBowSpecialAttack.kt | 57 +- .../ranged/DorgeshuunCrossbowSpecialAttack.kt | 163 +++ .../ranged/DragonCrossbowSpecialAttack.kt | 179 +++ .../ranged/DragonKnifeSpecialAttack.kt | 136 ++ .../ranged/DragonThrownaxeSpecialAttack.kt | 104 ++ .../ranged/EclipseAtlatlSpecialAttack.kt | 142 ++ .../attacks/ranged/MagicBowSpecialAttack.kt | 145 ++ .../ranged/MagicShortbowSpecialAttack.kt | 176 +++ .../ranged/MorrigansJavelinSpecialAttack.kt | 264 ++++ .../MorrigansThrowingAxeSpecialAttack.kt | 217 +++ .../ranged/RangedSpecialAttackEffects.kt | 15 + .../ranged/RosewoodBlowpipeSpecialAttack.kt | 146 ++ .../ranged/RuneThrownaxeSpecialAttack.kt | 356 +++++ .../attacks/ranged/SeercullSpecialAttack.kt | 154 +++ .../ranged/TonalzticsOfRalosSpecialAttack.kt | 204 +++ .../ranged/ToxicBlowpipeSpecialAttack.kt | 189 +++ .../ranged/WebweaverBowSpecialAttack.kt | 244 ++++ .../ranged/ZaryteCrossbowSpecialAttack.kt | 284 ++++ .../attacks/boost/DragonBattleaxeDrainTest.kt | 23 + .../magic/NightmareStaffSpecialDamageTest.kt | 30 + .../melee/AbyssalBludgeonDamageTest.kt | 20 + .../attacks/melee/AbyssalDaggerTimingTest.kt | 16 + .../attacks/melee/BloodSacrificeHealTest.kt | 33 + .../attacks/melee/BlueMoonSpearDamageTest.kt | 22 + .../attacks/melee/BoneDaggerBackstabTest.kt | 41 + .../attacks/melee/BrineSabreLiquifyTest.kt | 32 + .../attacks/melee/BrutalSwingDamageTest.kt | 30 + .../attacks/melee/BurningClawDamageTest.kt | 83 ++ .../attacks/melee/DragonClawsDamageTest.kt | 252 ++++ .../attacks/melee/DualMacuahuitlDamageTest.kt | 80 ++ .../attacks/melee/GraniteMaulStyleTest.kt | 29 + .../melee/ImpactMeleeSpecialAttacksTest.kt | 41 + .../attacks/melee/OsmumtenFangDamageTest.kt | 23 + .../attacks/melee/SoulreaperAxeDamageTest.kt | 79 ++ .../attacks/melee/UnleashMultipliersTest.kt | 28 + .../melee/VestaLongswordFeintDamageTest.kt | 65 + .../ArmadylCrossbowSpecialDamageTest.kt | 37 + .../attacks/ranged/BallistaAnimationTest.kt | 38 + .../attacks/ranged/DarkBowDamageTest.kt | 75 ++ .../ranged/DragonCrossbowSpecialDamageTest.kt | 20 + .../attacks/ranged/DragonKnifeVariantTest.kt | 24 + .../attacks/ranged/EclipseAtlatlDamageTest.kt | 21 + .../attacks/ranged/MagicBowDamageTest.kt | 49 + .../ranged/MorrigansJavelinBleedDamageTest.kt | 24 + .../ranged/MorrigansThrowingAxeDamageTest.kt | 50 + .../attacks/ranged/SoulshotDamageTest.kt | 19 + .../TonalzticsOfRalosSpecialDamageTest.kt | 23 + .../attacks/ranged/ToxicBlowpipeDamageTest.kt | 14 + .../attacks/ranged/WebweaverBowDamageTest.kt | 17 + .../ranged/ZaryteCrossbowSpecialDamageTest.kt | 31 + .../other/special-weapons/build.gradle.kts | 2 + .../special/weapons/MeleeWeaponsModule.kt | 14 + .../special/weapons/RangedWeaponsModule.kt | 4 + .../weapons/melee/CrimsonKistenWeapons.kt | 92 ++ .../special/weapons/melee/DogswordWeapons.kt | 87 ++ .../weapons/melee/FangOfTheHoundWeapons.kt | 147 +++ .../weapons/melee/NoxiousHalberdWeapons.kt | 123 ++ .../weapons/melee/SoulreaperAxeWeapons.kt | 84 ++ .../special/weapons/melee/SunspearWeapons.kt | 88 ++ .../weapons/melee/ThunderKhopeshWeapons.kt | 168 +++ .../special/weapons/ranged/BlowpipeWeapons.kt | 218 +++ .../weapons/ranged/WebweaverBowWeapons.kt | 136 ++ .../scripts/TrailblazerToolWieldFix.kt | 57 + .../scripts/charge/BlowpipeCharging.kt | 203 +++ .../scripts/charge/WebweaverBowCharging.kt | 153 +++ .../weapons/melee/SoulreaperStackGainTest.kt | 18 + .../org/rsmod/game/entity/PathingEntity.kt | 11 + .../dev/openrune/tools/MinifyServerCache.kt | 6 + 184 files changed, 17600 insertions(+), 229 deletions(-) create mode 100644 api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/BindEffectService.kt create mode 100644 api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMath.kt create mode 100644 api/combat/combat-commons/src/main/kotlin/org/rsmod/api/combat/commons/ShoveStunService.kt create mode 100644 api/combat/combat-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt create mode 100644 api/combat/combat-formulas/src/main/kotlin/org/rsmod/api/combat/formulas/maxhit/ranged/EclipseAtlatlMaxHit.kt create mode 100644 api/combat/combat-scripts/src/main/kotlin/org/rsmod/api/combat/player/PvPAreaAttackManager.kt create mode 100644 api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/BurnEffectService.kt create mode 100644 api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/EclipseAtlatlBurnEffect.kt create mode 100644 api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NoxiousHalberdVirulence.kt create mode 100644 api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonEffectService.kt create mode 100644 api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecay.kt create mode 100644 api/mechanics/toxins/src/main/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonEffect.kt create mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt create mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt create mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt create mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt create mode 100644 api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/BypassProtectionPrayerPlayerHitModifier.kt create mode 100644 api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/MorriganHamstring.kt create mode 100644 api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/PowerOfDeathMeleeProtection.kt create mode 100644 api/player/src/main/kotlin/org/rsmod/api/player/hit/modifier/VestaSpearCombatImmunity.kt create mode 100644 api/player/src/main/kotlin/org/rsmod/api/player/prayer/ProtectionPrayerLockout.kt create mode 100644 api/player/src/main/kotlin/org/rsmod/api/player/ranged/BlowpipeAmmo.kt create mode 100644 api/specials/src/main/kotlin/org/rsmod/api/specials/NextCycleRangedSpecialTiming.kt create mode 100644 content/other/consumables/src/main/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeScript.kt create mode 100644 content/other/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt create mode 100644 content/other/special-attacks/PROGRESS.md create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialAttacks.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/magic/StaffOfTheDeadSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalWhipSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientGodswordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AncientMaceSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AreaMeleeTargetSelector.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArkanBladeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArmadylGodswordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BarrelchestAnchorSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawsSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrimsonKistenSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrystalHalberdSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DemonbaneSpecialAttacks.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DinhsBulwarkSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DogswordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/Dragon2hSwordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonCandleDaggerSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonDaggerSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHalberdSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHastaSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonMaceSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonScimitarSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonSwordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ElderMaulSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/FangOfTheHoundSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteHammerSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/HalberdSpecialVisuals.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacks.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/InfernalTecpatlSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/NoxiousHalberdSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/RuneClawsSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominBlessedSwordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominSwordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperStackDecayScript.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/StatiusWarhammerSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SunspearSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ThunderKhopeshSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VampyreFlailSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaSpearSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VoidwakerSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DorgeshuunCrossbowSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonThrownaxeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicShortbowSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RangedSpecialAttackEffects.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RosewoodBlowpipeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RuneThrownaxeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/SeercullSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowSpecialAttack.kt create mode 100644 content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialAttack.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt create mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/CrimsonKistenWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/DogswordWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/FangOfTheHoundWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/NoxiousHalberdWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperAxeWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/SunspearWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/melee/ThunderKhopeshWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/BlowpipeWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/WebweaverBowWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/TrailblazerToolWieldFix.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/BlowpipeCharging.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/WebweaverBowCharging.kt create mode 100644 content/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt 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..b1a7f0c9a 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" @@ -21219,6 +21244,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 +23005,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 +23028,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]] @@ -23395,6 +23451,29 @@ weaponCategory="Axe" "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.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.toxic_blowpipe_loaded_ornament" inherit = "obj.toxic_blowpipe_loaded_ornament" @@ -23524,6 +23603,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 +23695,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 +35080,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 +35098,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 +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]] @@ -35026,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]] @@ -35035,6 +35152,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 +35170,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 +35188,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 +35205,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 +35225,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 +35242,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 +35260,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 +35285,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 +35310,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 +35335,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 +35360,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 +35385,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 +35411,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 +35681,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 +47295,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 +47303,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 +49872,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 +49933,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 +50185,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 +50255,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 +50328,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 +50452,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 +50596,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 +50626,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 +50671,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 +50942,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 +50967,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 +51076,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 +51297,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 +51337,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 +51369,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 +51641,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 +51740,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 +51885,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 +51921,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 +52127,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 +52195,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 +52225,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 +52324,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 +52546,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 +52850,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 +53005,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 +53178,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 +53305,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 +53448,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-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt b/api/combat/combat-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt new file mode 100644 index 000000000..9cfd434ca --- /dev/null +++ b/api/combat/combat-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt @@ -0,0 +1,34 @@ +package org.rsmod.api.combat.commons + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class ExpiringDurationMathTest { + @Test + fun `until clock is the current clock plus the duration`() { + assertEquals(105, ExpiringDurationMath.untilClock(currentClock = 100, ticks = 5)) + } + + @Test + fun `a longer application extends, a shorter or equal one is ignored`() { + assertTrue(ExpiringDurationMath.shouldExtend(newUntil = 110, currentUntil = 100)) + assertFalse(ExpiringDurationMath.shouldExtend(newUntil = 100, currentUntil = 100)) + assertFalse(ExpiringDurationMath.shouldExtend(newUntil = 90, currentUntil = 100)) + } + + @Test + fun `remaining ticks never goes negative`() { + assertEquals(5, ExpiringDurationMath.remaining(until = 105, currentClock = 100)) + assertEquals(0, ExpiringDurationMath.remaining(until = 100, currentClock = 100)) + assertEquals(0, ExpiringDurationMath.remaining(until = 90, currentClock = 100)) + } + + @Test + fun `a scheduled clear only fires if nothing extended the duration since`() { + assertTrue(ExpiringDurationMath.isStillCurrent(storedUntil = 105, expectedUntil = 105)) + assertFalse(ExpiringDurationMath.isStillCurrent(storedUntil = 120, expectedUntil = 105)) + assertFalse(ExpiringDurationMath.isStillCurrent(storedUntil = null, expectedUntil = 105)) + } +} 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..a96035b4f 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 @@ -33,6 +33,8 @@ 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.modifier.PlayerHitModifier +import org.rsmod.api.player.hit.modifier.StandardPlayerHitModifier import org.rsmod.api.player.hit.queueHit import org.rsmod.api.player.cheat.adminMaxHit import org.rsmod.api.player.interact.NpcInteractions @@ -540,16 +542,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 +580,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Boolean = accuracy.rollMeleeAccuracy( player = source, @@ -570,6 +590,7 @@ constructor( blockType = blockType, specMultiplier = specMultiplier, random = random, + defenceMultiplier = defenceMultiplier, ) private fun rollMeleeAccuracy( @@ -579,6 +600,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, specMultiplier: Double, + defenceMultiplier: Double = 1.0, ): Boolean = accuracy.rollMeleeAccuracy( player = source, @@ -588,6 +610,7 @@ constructor( blockType = blockType, specMultiplier = specMultiplier, random = random, + defenceMultiplier = defenceMultiplier, ) /** @@ -697,10 +720,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 +738,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..1670341be 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 @@ -8,6 +8,8 @@ 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 +import org.rsmod.api.mechanics.toxins.EclipseAtlatlBurnEffect +import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect import org.rsmod.api.combat.player.activateMagicSpecial import org.rsmod.api.combat.player.activateMeleeSpecial import org.rsmod.api.combat.player.activateRangedSpecial @@ -23,6 +25,7 @@ import org.rsmod.api.player.quiver import org.rsmod.api.player.righthand import org.rsmod.api.specials.SpecialAttackRegistry import org.rsmod.api.specials.SpecialAttackType +import org.rsmod.api.specials.NextCycleRangedSpecialTiming import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.spells.attack.SpellAttackRegistry import org.rsmod.api.spells.attack.attack @@ -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 } @@ -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!") 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..365d09ae4 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 @@ -15,6 +15,8 @@ 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.mechanics.toxins.EclipseAtlatlBurnEffect +import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect import org.rsmod.api.config.constants import org.rsmod.api.config.refs.params import org.rsmod.api.player.isValidTarget @@ -24,6 +26,7 @@ import org.rsmod.api.player.quiver import org.rsmod.api.player.righthand import org.rsmod.api.specials.SpecialAttackRegistry import org.rsmod.api.specials.SpecialAttackType +import org.rsmod.api.specials.NextCycleRangedSpecialTiming import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.spells.attack.SpellAttackRegistry import org.rsmod.api.spells.attack.attack @@ -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 } @@ -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!") 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..d6f805fb7 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 @@ -153,7 +153,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 +187,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 +219,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/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt new file mode 100644 index 000000000..8093b1ba6 --- /dev/null +++ b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt @@ -0,0 +1,41 @@ +package org.rsmod.api.mechanics.toxins + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BurnStacksTest { + @Test + fun `allows applying up to five stacks but discards a sixth`() { + assertTrue(BurnStacks.canApply(currentStackCount = 0)) + assertTrue(BurnStacks.canApply(currentStackCount = 4)) + assertFalse(BurnStacks.canApply(currentStackCount = 5)) + } + + @Test + fun `pulse damage equals the active stack count, capped at five`() { + assertEquals(0, BurnStacks.damageForStackCount(0)) + assertEquals(1, BurnStacks.damageForStackCount(1)) + assertEquals(3, BurnStacks.damageForStackCount(3)) + assertEquals(5, BurnStacks.damageForStackCount(5)) + } + + @Test + fun `tick decrements every stack by one interval and drops expired ones`() { + val stacks = listOf(40, 8, 4) + assertEquals(listOf(36, 4), BurnStacks.tick(stacks)) + } + + @Test + fun `tick can empty the whole list in one pulse`() { + assertEquals(emptyList(), BurnStacks.tick(listOf(4, 4))) + } + + @Test + fun `remaining damage sums each stack's future pulses`() { + // A fresh 40-tick stack still owes 10 pulses; an 8-tick stack owes 2 more. + assertEquals(12, BurnStacks.remainingDamage(listOf(40, 8))) + assertEquals(0, BurnStacks.remainingDamage(emptyList())) + } +} diff --git a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt new file mode 100644 index 000000000..2bb90dc6d --- /dev/null +++ b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt @@ -0,0 +1,58 @@ +package org.rsmod.api.mechanics.toxins + +import org.rsmod.api.mechanics.toxins.impl.PlayerPoison +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class NpcPoisonOverrideTest { + @Test + fun `applies when the target has no active poison`() { + assertTrue(NpcPoisonOverride.shouldApply(newInitialDamage = 4, newSeverity = 16, currentSeverity = 0)) + } + + @Test + fun `a strictly weaker poison never overrides`() { + val current = PlayerPoison.severityForInitialDamage(6) + assertFalse( + NpcPoisonOverride.shouldApply( + newInitialDamage = 4, + newSeverity = PlayerPoison.severityForInitialDamage(4), + currentSeverity = current, + ) + ) + } + + @Test + fun `equal damage only overrides if the new severity is actually higher`() { + val current = PlayerPoison.severityForInitialDamage(4) + val sameDamageLowerSeverity = current - 1 + + assertFalse( + NpcPoisonOverride.shouldApply( + newInitialDamage = 4, + newSeverity = sameDamageLowerSeverity, + currentSeverity = current, + ) + ) + assertTrue( + NpcPoisonOverride.shouldApply( + newInitialDamage = 4, + newSeverity = current + 5, + currentSeverity = current, + ) + ) + } + + @Test + fun `a stronger poison always overrides`() { + val current = PlayerPoison.severityForInitialDamage(4) + assertTrue( + NpcPoisonOverride.shouldApply( + newInitialDamage = 10, + newSeverity = PlayerPoison.severityForInitialDamage(10), + currentSeverity = current, + ) + ) + } +} diff --git a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt new file mode 100644 index 000000000..91ac33693 --- /dev/null +++ b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt @@ -0,0 +1,33 @@ +package org.rsmod.api.mechanics.toxins + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SoulreaperStackDecayTest { + @Test + fun `interval is 50 cycles, matching the wiki's 50 ticks (30s)`() { + assertEquals(50, SoulreaperStackDecay.INTERVAL_CYCLES) + } + + @Test + fun `next deadline is 50 cycles after the reset clock`() { + assertEquals(150, SoulreaperStackDecay.nextDeadline(clock = 100)) + } + + @Test + fun `not due before the deadline`() { + assertFalse(SoulreaperStackDecay.isDue(clock = 149, deadline = 150)) + } + + @Test + fun `due exactly at the deadline`() { + assertTrue(SoulreaperStackDecay.isDue(clock = 150, deadline = 150)) + } + + @Test + fun `still due any time after the deadline`() { + assertTrue(SoulreaperStackDecay.isDue(clock = 999, deadline = 150)) + } +} diff --git a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt new file mode 100644 index 000000000..17ed0a219 --- /dev/null +++ b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt @@ -0,0 +1,46 @@ +package org.rsmod.api.mechanics.toxins + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class WeaponPoisonTierTest { + @Test + fun `bare (p) suffix resolves to the regular tier`() { + assertEquals(WeaponPoisonTier.Regular, WeaponPoisonTier.of("obj.dragon_dagger_p")) + assertEquals(4, WeaponPoisonTier.of("obj.dragon_dagger_p")?.meleeDamage) + assertEquals(2, WeaponPoisonTier.of("obj.rune_dart_p")?.rangedDamage) + } + + @Test + fun `(p+) suffix resolves to the plus tier and not the regular tier`() { + assertEquals(WeaponPoisonTier.Plus, WeaponPoisonTier.of("obj.rune_spear_p+")) + assertEquals(5, WeaponPoisonTier.of("obj.rune_spear_p+")?.meleeDamage) + assertEquals(3, WeaponPoisonTier.of("obj.rune_dart_p+")?.rangedDamage) + } + + @Test + fun `(p++) suffix resolves to the strongest tier`() { + assertEquals(WeaponPoisonTier.PlusPlus, WeaponPoisonTier.of("obj.abyssal_dagger_p++")) + assertEquals(6, WeaponPoisonTier.of("obj.abyssal_dagger_p++")?.meleeDamage) + assertEquals(4, WeaponPoisonTier.of("obj.rune_dart_p++")?.rangedDamage) + } + + @Test + fun `an unpoisoned weapon resolves to no tier`() { + assertNull(WeaponPoisonTier.of("obj.dragon_dagger")) + assertNull(WeaponPoisonTier.of("obj.rune_spear")) + } + + @Test + fun `a null alias resolves to no tier`() { + assertNull(WeaponPoisonTier.of(null)) + } + + @Test + fun `an unrelated name ending in p is not mistaken for poison`() { + // Regression guard for the suffix-matching approach: only an exact "_p"/"_p+"/"_p++" + // trailer should match, not any name that merely contains "p" near the end. + assertNull(WeaponPoisonTier.of("obj.iron_dagger_pouch")) + } +} 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..4dc037e8c 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)" @@ -109,7 +109,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..97fe2ce7d --- /dev/null +++ b/api/player/src/main/kotlin/org/rsmod/api/player/ranged/BlowpipeAmmo.kt @@ -0,0 +1,393 @@ +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/ui/PlayerInterfaceExtensions.kt b/api/player/src/main/kotlin/org/rsmod/api/player/ui/PlayerInterfaceExtensions.kt index 6ef8d2e6e..3db5c7a2f 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 @@ -306,6 +306,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/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..6f3b881a8 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 @@ -8,12 +8,15 @@ 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 +41,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 +149,7 @@ constructor( attackStyle: MeleeAttackStyle?, blockType: MeleeAttackType?, multiplier: Double, + defenceMultiplier: Double = 1.0, ): Boolean = manager.rollMeleeAccuracy( source = source.player, @@ -147,6 +158,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 +218,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 +375,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..3ab3df7ca 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 @@ -6,6 +6,7 @@ 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 +199,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..e57170f6d 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 @@ -42,8 +42,11 @@ import org.rsmod.api.script.onPlayerQueue import org.rsmod.api.script.onPlayerQueueWithArgs import org.rsmod.api.specials.SpecialAttack import org.rsmod.api.specials.SpecialAttackRegistry +import org.rsmod.api.specials.NextCycleRangedSpecialTiming 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 @@ -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..b78503e93 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 @@ -7,6 +7,7 @@ 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.protect.ProtectedAccess +import org.rsmod.api.player.prayer.ProtectionPrayerLockout import org.rsmod.api.player.protect.ProtectedAccessLauncher import org.rsmod.api.player.stat.prayerLvl import org.rsmod.api.player.ui.ifClose @@ -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/build.gradle.kts b/content/other/commands/build.gradle.kts index a2890fd92..351dc6359 100644 --- a/content/other/commands/build.gradle.kts +++ b/content/other/commands/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(projects.api.dbGateway) implementation(projects.api.mechanics.toxins) implementation(projects.api.pluginCommons) + implementation(projects.api.specials) implementation(projects.api.spellsAutocast) implementation(projects.api.utils.utilsSystem) 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..035ee4b82 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,8 +5,8 @@ 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 dev.openrune.util.Wearpos import jakarta.inject.Inject import kotlin.math.max import kotlin.math.min @@ -23,8 +23,10 @@ import org.rsmod.api.invtx.invClear import org.rsmod.api.mechanics.toxins.impl.PlayerDisease import org.rsmod.api.mechanics.toxins.impl.PlayerPoison import org.rsmod.api.mechanics.toxins.impl.PlayerVenom +import org.rsmod.api.obj.charges.ObjChargeManager import org.rsmod.api.player.cheat.adminGodMode import org.rsmod.api.player.cheat.adminMaxHit +import org.rsmod.api.player.righthand import org.rsmod.api.player.ironman.PlayerGamemode import org.rsmod.api.player.ironman.setGamemode import org.rsmod.api.player.debug.componentClickDebug @@ -45,6 +47,7 @@ import org.rsmod.api.player.vars.resyncVar import org.rsmod.api.registry.region.RegionRegistry import org.rsmod.api.repo.loc.LocRepository import org.rsmod.api.repo.npc.NpcRepository +import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.utils.format.formatAmount import org.rsmod.api.utils.system.SafeServiceExit import org.rsmod.game.GameUpdate @@ -80,6 +83,7 @@ constructor( private val regions: RegionRegistry, private val deathKillHooks: Set, private val instanceRegistry: BossInstanceRegistry, + private val objCharges: ObjChargeManager, ) : PluginScript() { private val logger = InlineLogger() @@ -90,6 +94,7 @@ constructor( override fun ScriptContext.startup() { onCommand("master", "Max out all stats", ::master) onCommand("reset", "Reset all stats", ::reset) + onCommand("me", "Fill special attack energy to max", ::maxSpecialEnergy) onCommand("mypos", "Get current coordinates", ::mypos) onCommand("tele", "Teleport to coordgrid", ::tele) { invalidArgs = "Usage: ::tele mx mz [level](e.g. ::tele 3200 3200 0)" @@ -131,7 +136,7 @@ constructor( } onCommand("invadd", "Spawn obj into inv", ::invAdd) - onCommand("item", "Spawn obj into inv (ex: ::item 995 100 or ::item coins 100)", ::invAdd) + onCommand("item", "Spawn obj into inv", ::invAdd) onCommand("invclear", "Remove all objs from inv", ::invClear) onCommand("varp", "Set varp value", ::setVarp) { @@ -158,6 +163,14 @@ constructor( } onCommand("venom", "Test player venom (escalating damage timer)", ::venomTest) onCommand("venomclear", "Clears Venom", ::venomClear) + onCommand( + "charge", + "Add charges to the currently wielded weapon (for testing - some raw materials, " + + "e.g. revenant ether, don't exist in this cache at all)", + ::chargeTest, + ) { + invalidArgs = "Use as ::charge varobjName amount (ex: ::charge charges_16383 5000)" + } onCommand("disease", "Test disease (drain per tick, default 3)", ::diseaseTest) { invalidArgs = "Use as ::disease [drainPerTick] (e.g. ::disease 5)" } @@ -268,6 +281,32 @@ constructor( private fun venomClear(cheat: Cheat) = with(cheat) { PlayerVenom.clear(player) } + private fun chargeTest(cheat: Cheat) = + with(cheat) { + if (player.righthand == null) { + player.mes("You aren't wielding anything.") + return@with + } + val varobjName = "varobj.${args[0]}" + val amount = args[1].toIntOrNull() + if (amount == null || amount <= 0) { + player.mes("Amount must be a positive number.") + return@with + } + // `max` is just a generous debug-tool ceiling, not the item's real cap - if it's too + // high for this specific varobj's actual bit width, the thrown message will report + // the real valid range to retry with. + val result = + objCharges.addCharges( + inventory = player.worn, + slot = Wearpos.RightHand.slot, + add = amount, + internal = varobjName, + max = Short.MAX_VALUE.toInt(), + ) + player.mes("Charge result: $result") + } + private fun diseaseTest(cheat: Cheat) = with(cheat) { val drain = args.getOrNull(0)?.toIntOrNull() ?: 3 @@ -291,6 +330,18 @@ constructor( private fun reset(cheat: Cheat) = with(cheat) { player.setStatLevels(level = 1) } + private fun maxSpecialEnergy(cheat: Cheat) = + with(cheat) { + val type = ServerCacheManager.getVarp("varp.sa_energy".asRSCM()) + if (type == null) { + player.mes("Could not find varp 'sa_energy'.") + return + } + player.vars.backing[type.id] = SpecialAttackEnergy.MAX_ENERGY + player.resyncVar(type) + player.mes("Special attack energy filled to max.") + } + private fun mypos(cheat: Cheat) = with(cheat) { player.mes("${player.coords}:") @@ -508,16 +559,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 +819,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/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt b/content/other/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt new file mode 100644 index 000000000..7ec530f85 --- /dev/null +++ b/content/other/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt @@ -0,0 +1,41 @@ +package org.rsmod.content.other.consumables.food + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DwarvenRockCakeDamageTest { + @Test + fun `eating deals 1 damage above 2 hitpoints`() { + assertEquals(1, DwarvenRockCakeDamage.eatDamage(3)) + assertEquals(1, DwarvenRockCakeDamage.eatDamage(99)) + } + + @Test + fun `eating does nothing at or below 2 hitpoints`() { + assertEquals(0, DwarvenRockCakeDamage.eatDamage(2)) + assertEquals(0, DwarvenRockCakeDamage.eatDamage(1)) + } + + @Test + fun `guzzling deals 10 percent plus one`() { + assertEquals(10, DwarvenRockCakeDamage.guzzleDamage(99)) + assertEquals(2, DwarvenRockCakeDamage.guzzleDamage(10)) + } + + @Test + fun `guzzling at 2 hitpoints brings the player to exactly 1`() { + assertEquals(1, DwarvenRockCakeDamage.guzzleDamage(2)) + } + + @Test + fun `guzzling at 1 hitpoint deals zero damage`() { + assertEquals(0, DwarvenRockCakeDamage.guzzleDamage(1)) + } + + @Test + fun `guzzling never reduces hitpoints below 1, even where the raw formula would`() { + // At 3 HP the raw 10%+1 formula gives 1, which is safe (3-1=2 remains) - the clamp only + // bites right at the boundary (2 HP and below), covered by the tests above. + assertEquals(1, DwarvenRockCakeDamage.guzzleDamage(3)) + } +} diff --git a/content/other/special-attacks/PROGRESS.md b/content/other/special-attacks/PROGRESS.md new file mode 100644 index 000000000..74c4674a1 --- /dev/null +++ b/content/other/special-attacks/PROGRESS.md @@ -0,0 +1,1176 @@ +# Special attack verification progress + +Tier A = pure damage/accuracy specials, no engine dependency. Tier B = applies an effect to +something/someone else - originally thought to need a separate, risky engine-diff patch +(`HitImpactHandler` touching core `Npc`/`Player`/`PathingEntity` classes). That premise turned out +to be wrong for most of Tier B - see "Tier B breakthrough" below. + +**Build status: 73 of 73 total weapons compile and run - the source set is now complete.** Since +the last count (70): Bone dagger, Dorgeshuun crossbow, and Vampyre flail (Ivandis/Blisterwood/ +Hallowed) - see "Last three weapons unblocked" below. Nothing remains in `disabled-tier-b/`. + +`compileKotlin`, `compileTestKotlin`, and `test` all exit 0 for everything currently in the source +set. Test files: written for the weapons with genuine custom math (see "Test files" section below) +- the rest (mostly flat accuracy/damage multipliers) don't have much to test beyond "is this +constant right," not yet done. + +## Tier B breakthrough: most of it never needed the engine-diff patch at all + +You asked "if you can do all 47, let's go" - traced every `HitImpactHandler` usage across all 47 +Tier B files and found the real split was **22 easy** (just needed a mechanical rewrite) vs **26 +hard** (need real new subsystems). Two things made the "easy" 22 possible without ever touching +`Npc.kt`/`Player.kt`/`PathingEntity.kt`: + +1. **Real OSRS doesn't clamp damage after the roll** (established earlier tonight). Every + `HitImpactHandler` used only to gate or scale an effect off "how much damage this hit dealt" can + just use the already-known, pre-clamp `damage` value computed a few lines earlier, synchronously + - no callback needed. This covered the large majority of cases (Abyssal whip's energy drain, + Ancient mace/Dragon scimitar/Barrelchest anchor/Elder maul/Statius warhammer/Dogsword's stat + drains, Saradomin sword/Voidwaker's bonus magic xp, the Armadyl-enchanted-bolt effect family + shared by Armadyl crossbow and Zaryte crossbow, Seercull's magic drain, Morrigan's javelin's + bleed, Tonalztics of Ralos's defence drain, Nightmare staff's prayer restore). +2. **For the one genuinely delayed case** (Ancient godsword's mark -> 8-tick-later heal), the + damage was already a fixed, known-in-advance value (not a live roll) - so instead of a callback, + the world-queue mechanism already used elsewhere in that same file just re-schedules the heal + for the same delay the hit itself uses. + +Three real (small) pieces of shared infrastructure came out of this, all outside the +`HitImpactHandler`/engine-diff family: +- **`BypassProtectionPrayerPlayerHitModifier`** (`api/player/hit/modifier/`) - Ancient mace and + Dragon sword's Wild Stab both ignore Protect from Melee entirely; added as a sibling to the + existing `StandardPlayerHitModifier`, and threaded an optional `modifier` param through + `PlayerAttackManager.queueMeleeHit`/`SpecialAttackManager.queueMeleeHit` (default preserves every + other caller's behavior). +- **`PowerOfDeathMeleeProtection`** (`api/player/hit/modifier/`) - Staff of the Dead's Power of + Death (halve incoming melee damage for 60s while the staff stays equipped). Wired directly into + `StandardPlayerHitModifier` since it must apply to *all* incoming melee, not just special-attack + hits. +- **`ProtectionPrayerLockout`** + `Player.disableProtectionPrayers()` (`api/player/prayer/`) - + Dragon scimitar's Sever (PvP): disables active protection prayers and blocks re-activating them + for 8 ticks. Required one small hook in `PrayerTabScript.enablePrayer` (the prayer tab's own + activation entry point) to check the lockout before allowing a protection prayer back on. +- **`defenceMultiplier` threaded through the melee accuracy formula chain** (`PvNMeleeAccuracy`, + `PvPMeleeAccuracy`, `AccuracyFormulae`, `PlayerAttackManager`, `SpecialAttackManager`) - Vesta's + longsword's Feint rolls against 25% of the target's defence rather than boosting its own attack + roll (confirmed these aren't mathematically interchangeable - the real hit-chance formula isn't + symmetric). Default `1.0` preserves every other caller. + +All confirmed via wiki before writing (Dragon scimitar's exact "8 ticks (4.8s)" lockout, Staff of +the Dead's "one minute... lost immediately if the staff is unequipped", Vesta's longsword's +"rolled against 25% of the opponent's defence"). + +### Last three weapons unblocked (resolved - Bone dagger, Dorgeshuun crossbow, Vampyre flail) + +Revisited both previously-deferred gaps and found each was smaller than the earlier note assumed +once actually traced end to end, rather than estimated from the surface. + +**`lastDamagingPlayerUuid` (Bone dagger, Dorgeshuun crossbow)**: still a real core-engine touch, but +turned out to have an existing choke point to hang off rather than needing brand new plumbing. +`PathingEntity.recordDamage(source: Player, damage: Int)` already exists and is already called by +the *entire* shared hit-application pipeline for both NPC and player targets (`StandardNpcHitProcessor` +directly, `StandardPlayerHitProcessor` via `recordHitDamage` -> `Hit.recordDamageOn`) - it's the +same function that already powers `heroPoints`/`damageContributions` for loot/XP-sharing on group +kills, so it was already guaranteed to fire on every positive-damage hit from any weapon, special or +not. Added one field, `PathingEntity.lastDamagingPlayerUuid: Long?`, and one line inside that +existing function (`lastDamagingPlayerUuid = source.uuid`) - zero new call sites, zero behavior +change for anything else that reads `heroPoints`/`damageContributions`. + +**Vampyre flail's Retainer**: all three "smaller pieces" the earlier note wanted verified +(`clearQueue`, `clearInteraction`, `npcChangeType(npc, into, duration)`) already existed with the +exact signatures the disabled file called - nothing to add there. The one apparently-missing piece, +`Npc.retainerUntil`, turned out to be dead state: it was only ever *written*, never read anywhere, +and `npcChangeType`'s own `duration` parameter already auto-reverts the NPC back to its original +type after the given cycle count - so the "trapped for 30 seconds" behavior was already fully +handled by the engine primitive, not by the field. Removed the two `target.retainerUntil = ...` +lines rather than inventing an unused field. + +Both files also still referenced the pre-"Tier B breakthrough" `HitImpactHandler`/`impactHandler` +callback API (removed everywhere else in this project already, see above) - modernized both to the +same synchronous pre-clamp-`damage` pattern used by every other converted Tier B weapon. Also +carried the recurring `height = 96` target-hit-spotanim bug fix (see Osmumten fang/AGS above) into +all three files' target-facing impact spotanims while in there. + +`obj.hallowed_flail` had no `items.toml` block at all (unlike its two siblings, `ivandis_flail`/ +`blisterwood_flail`, which both already had full animation data) - added one mirroring the other +two exactly, since all three flail tiers share the identical `seq.ivandis_flail_*` animation set. + +All three moved out of `disabled-tier-b/` into the live source set and registered in +`SpecialAttackModule.kt`. `BoneDaggerBackstabTest.kt` (pure-logic test for the "was I the target's +last damager" check, already written and waiting) moved with it and passes. Full project +`compileKotlin`/`test` passes. Cache rebuilt (the `hallowed_flail` toml addition needed it). Server +restarted clean, zero errors in the boot log - including zero `SpecialAttackRegistry` collisions, +worth checking explicitly given the Seercull/`daganoth_cave_magic_shortbow` id-collision crash +found earlier this same pass. + +### BlowpipeAmmo built (resolved - Toxic blowpipe, Rosewood blowpipe converted) + +Both special attack files (`ToxicBlowpipeSpecialAttack.kt`, `RosewoodBlowpipeSpecialAttack.kt`) +were already fully written, sitting in `disabled-tier-b/` and referencing a +`org.rsmod.api.player.ranged.BlowpipeAmmo` API that simply didn't exist in this repo yet. Checked +torka's Downloads contribution first (per the established habit this session) and found the +*complete* class already written there +(`OpenRune-Special-Attack-API-Engine-Diff/.../api/player/ranged/BlowpipeAmmo.kt`) - copied it in +directly and it compiled clean on the first try against this repo's current API surface. Real +mechanism: both blowpipe families pack their dart type/count (and, toxic only, scale count) into +the SAME per-item `vars` bitfield `ObjChargeManager` already uses for single-counter charge +weapons - `varobj.snakeboss_blowpipe_darttype`/`_dartcount`/`_flakes`, three independent bit +ranges on the one packed value, all real, pre-existing cache varobjs (confirmed via +`.data/gamevals/varobj.rscm`). `getBits`/`withBits` (same utils `ObjChargeManager` uses) read/write +each field independently. + +**One real bug fixed in the imported file**: the wiki (current, 2025) says the rosewood blowpipe +"is able to shoot up to rune darts," but torka's file capped its `maxDartIndex` at adamant - fixed +the constant (`RUNE_DART_INDEX = 6`, was `ADAMANT_DART_INDEX = 5`) and its doc comment before +deploying, per the standing verify-against-wiki-before-trusting-any-source discipline. + +**What was actually still missing, since torka's contribution only had the special attacks**: +- **Normal attacks**: both blowpipes store ammo *inside the weapon item itself*, not the quiver - + the generic cache-driven ranged fallback (`PvNCombat`/`PvPCombat`) only knows about quiver ammo, + so neither blowpipe could fire a normal attack at all without a dedicated `WeaponMap`. Built + `BlowpipeWeapons.kt` (`content/other/special-weapons/.../ranged/`), mirroring each special + file's own exact logic (dart/scale conservation rolls, 25%/100%-with-serpentine-helm venom + chance for toxic, no venom at all for rosewood per the wiki) but with 1x multipliers instead of + the special's boosted ones. +- **`EquipmentChecks.isSerpentineHelm`** - referenced by the special files but didn't exist; added + (covers the base, charged, and both cyan/red-recolor variants). +- **`obj.rosewood_blowpipe`'s combat data** - the base cache import had no `weaponCategory` or any + `attack_anim_stance*`/`bas_*` params at all (same missing-combat-data pattern as several other + recently-added weapons this session) - added a full `items.toml` override using its own real + named animation (`seq.rosewood_blowpipe_attack`, + confirmed via `gameval_search`) plus the same generic `bas_*` wield-stance set toxic blowpipe + already uses (no dedicated named aliases exist for rosewood's own idle/walk poses). +- **Loading darts/scales**: `BlowpipeAmmo.storeDarts`/`storeScales` existed as pure functions but + nothing called them - a blowpipe could never actually get ammo into it. Built + `BlowpipeCharging.kt` (`onOpHeldU` for every dart-type/blowpipe-variant pair, plus scales for the + toxic family), following the exact same pattern as `WebweaverBowCharging.kt`. **Unload/Uncharge + (returning stored ammo to the inventory) are not wired up yet** - `BlowpipeAmmo.unloadDarts`/ + `uncharge` exist and are ready, just need the actual `onOpHeld*` option hookup; a smaller, + separate follow-up. + +`compileKotlin`/`compileTestKotlin`/`test` all exit 0 project-wide; cache rebuilt (new +`rosewood_blowpipe` items.toml block); server restarted clean. + +**Two follow-up bugs found live, both fixed**: +- **`BlowpipeWeapons` was never added to `RangedWeaponsModule.kt`'s bindings.** Unlike + `PluginScript`s (auto-discovered), `WeaponMap` implementations need an explicit + `addSetBinding(...)` or they're silently never invoked - rosewood blowpipe's normal + attack fell through to the generic ranged fallback, which fails for a different reason (blowpipe + ammo isn't in the quiver) but happens to print the exact same message text ("You are unable to + fire your ammunition."), making it look identical to the dedicated handler's own failure branch. + Fixed; both blowpipes' normal attacks confirmed working live. +- **`Toxic Siphon`'s spotanim was played on the player at `height = 96`** - the same blind-copy- + from-Dragon-claws height pattern fixed on many other specials this session, but the deeper issue + the user caught: `spotanim.toxic_blowpipe_specialattack` (id 1043) is the special's own flying + projectile effect, not a player-cast effect at all - it was being played statically at the + player's feet instead of as the projectile heading to the target. Fixed by passing it as the + `spawnProjectile` spotanim (replacing the dart's own generic travel effect for this hit) instead + of a separate `spotanim(...)` call on the player. The animation/spotanim aliases themselves + (`seq.toxic_blowpipe_special_updated` id 876, the spotanim id 1043) were always correct - only + how they were used was wrong. + +**A real damage bug found live and fixed**: reported as "regular rune dart hits harder than the +same rune dart loaded in the blowpipe," which is backwards - the blowpipe should always hit at +least as hard (it adds its own +20 ranged strength on top). Root cause: the loaded dart's own +`ranged_strength` bonus (rune dart: +26, confirmed via cache) is never counted at all when firing +from the blowpipe. The standard equipment-bonus scan (`WornBonuses.calculate`) only sums params off +items actually worn in a `Wearpos` slot - a blowpipe's darts are packed inside the weapon's own +`vars` bitfield instead (see [[blowpipe-ammo-built-from-torkas-source]]), so they were invisible to +it entirely, even though `BlowpipeAmmo.rangedStrengthBonus(obj)` already existed (from torka's +original file) specifically for this purpose and was simply never wired in anywhere. Fixed with the +same one-line-per-formula pattern as the Eclipse atlatl max hit fix: added +`BlowpipeAmmo.rangedStrengthBonus(source.righthand)` to the `rangedBonus` term in both +`PvNRangedMaxHit`/`PvPRangedMaxHit.computeModifiedDamage` - a safe no-op (returns 0) for every +weapon that isn't a loaded blowpipe, since `BlowpipeAmmo.loadedDart` returns null otherwise. Covers +both the special and normal attacks in one fix, same shared-formula-chain reasoning as the atlatl. +Compiled/tested clean project-wide; no cache changes needed; server restarted clean. + +**Check/Unload/Uncharge built**, confirmed against a real cache dump rather than guessed - the +live wiki cache (`osrs-wiki-cache-utils`) still has the un-league-ified real toxic/rosewood +blowpipe `config/obj` entries, showing the exact op layout: toxic loaded variants +(`iop2=Wield, iop3=Check, iop4=Unload, iop5=Uncharge`), rosewood loaded +(`iop2=Wield, iop3=Check, iop5=Unload` - no Uncharge, since it has no scales, and its removal op +sits at position 5 instead of 4). `param.wear_op1=Check` confirmed the same "Check" option is also +available while worn, replacing the item's default unequip-by-op1 (real Jagex behavior on this +specific weapon - it can still be removed via the equipment interface). Implemented with +`onOpHeld3`/`onOpHeld4`/`onOpHeld5`/`onOpWorn1`, reusing `BlowpipeAmmo.unloadDarts`/`uncharge` +(present in torka's original file, unused until now) for the actual state changes, and +`invAddOrDropType`/`choice2` (same pattern as `TumekensShadowCharging.uncharge`) for returning +ammo to the inventory with a confirmation prompt. + +**A real bug from the first pass, caught live and fixed**: also wired up Check-while-worn via +`onOpWorn1`, since `param.wear_op1=Check` on the real item. `onOpWorn1`'s own doc explicitly warns +it "replaces the default unequip op handling" - and in this engine, op1 while worn is "Remove" +*regardless* of what `wear_op1` claims the real client would show, so registering it made the +Remove button silently run Check instead of unequipping (reported as "the remove option shows +scales and darts, not the check option"). Removed the `onOpWorn1` registration - held `Check` (op3) was +untouched. User confirmed a "Check" option genuinely exists on the equipment-tab screen too, so +this needed fixing properly rather than dropping: switched to `onOpWorn2`, the exact same slot +`TumekensShadowCharging` already uses for its own worn-Check - a real, free slot in this engine +that doesn't touch Remove. + +**Player animation bug found live and fixed**: reported as "character just stands still while the +projectile fires" during Toxic Siphon. Root cause: `seq.toxic_blowpipe_special_updated` (used for +the spotanim fix earlier) was ALSO being played on the player via `anim(...)` - but a real cache +dump (`osrs-wiki-cache-utils`) showed it has none of the markers a real player-body seq carries +(`replaceheldright`, `walkmerge`, `heightoffset` - all present on e.g. Rosewood's own +`rosewood_blowpipe_special_attack` seq, all absent here). It's the flying dart's own model +animation, referenced by the spotanim's `anim=` field, not something to separately trigger on the +player - so calling `anim()` with it did nothing visible on a player skeleton. Fixed by reusing the +weapon's own real throwing animation instead (reads `param.attack_anim_stance1` directly off the +weapon, so it naturally picks the right variant for the ornament kit too), matching how Rosewood +blowpipe's own special already uses its own dedicated player seq. + +**Projectile flew too high, fixed - and a real build-tooling bug found and fixed along the way**: +Toxic Siphon's flying dart used the shared `projanim.thrown` type (startHeight=163/endHeight=146, +same as every other thrown weapon), but this effect's own model looked far too high in the air at +those heights. Tried adding a brand-new dedicated `projanim.toxic_blowpipe_special` entry to +`projectiles.toml` with lower values (to avoid touching the shared type used by every other thrown +weapon) - this doesn't work the way `items.toml`/`param.rscm` custom aliases do: a raw-cache +`projectiles.toml` entry's `id` must already be a registered RSCM alias, not something you can +invent inline; hit `IllegalStateException: rsconfig constant mapping ... not found in table +'projanim'` during `buildCache`. Tried `projanim.dragonfire` (43/31) as an interim +workaround - reported too low. Went back and did it properly: found `.data/gamevals/projanim.rscm` +is a genuinely small, hand-editable alias table (18 lines, unlike `obj.rscm` which is effectively +empty and backed by a merged binary source elsewhere) - added `toxic_blowpipe_special=18` there, +then re-added the dedicated `projectiles.toml` entry (this time it built clean) with a middle- +ground height (95/70) between the too-high shared "thrown" type and the too-low dragonfire +experiment. Reported still slightly low - nudged both up +further (95/70 → 115/85). Still an unverified visual-tuning guess pending live confirmation. + +**Osmumten's fang special: same recurring height=96 bug, only just now caught.** Reported "a bit +too high" - `spotanim.spotanim_weapon_sword_osmumten_special` was still at the blind `height = 96` +copied from Dragon claws early this session, never touched since it wasn't flagged before now. +Dropped to `0`, same fix as every other file that's hit this exact pattern. + +While chasing that, also hit and fixed a **real, reproducible `:or-cache:buildCache` bug**, +unrelated to blowpipes specifically: `MinifyServerCache.kt`'s final step +(`temp.copyRecursively(loc, true)`) intermittently threw `FileAlreadyExistsException` overwriting +`loc`'s own cache files, 100% reproducible from a clean `.data/cache/SERVER/` deletion, a stopped +Gradle daemon, and no other process holding the cache open. Root cause: on Windows, closing the +`CacheLibrary` (line `cache.close()`) doesn't deterministically release its memory-mapped file +handles - JVM `MappedByteBuffer` unmapping isn't guaranteed until GC runs, and there's no explicit +unmap API available on this JDK. Added a `System.gc()` immediately before the overwrite copy; +rebuilt clean on the next attempt. A real, generally-applicable fix - not scoped to this session's +blowpipe work - worth remembering if `buildCache` ever throws this same exception again elsewhere. + +**One real UX quirk found, not resolved**: loading ammo only works as "use blowpipe on scales/darts," +not "use scales/darts on blowpipe" - the reverse direction does nothing, even though `onOpHeldU`'s +own doc claims click order shouldn't matter (the framework is meant to normalize which item is +`first`/`second` regardless of which the player clicks first). Spent a long diagnostic pass on this +(registration-time logging confirmed all handlers register with zero exceptions; interaction-time +logging showed the handler never firing for the "scale on blowpipe" direction) before the user found +the working direction empirically. Not chased further since it's unblocked, but a real, unexplained +discrepancy in this engine's click-order-independence claim - worth another look if it recurs +elsewhere. + +### Poison/venom + shove-stun/bind unlock (resolved - 7 weapons converted) + +Built three small, self-contained services this session, all following the same shape as +`BurnEffectService` above (in-memory, `WeakHashMap`-keyed, self-rescheduling via `WorldQueueList`, +no persistence across logout, no buff-bar/UI integration): + +- **`NpcPoisonEffectService`** (`api/mechanics/toxins/`) - NPC-side poison and venom, reusing + `PlayerPoison`/`PlayerVenom`'s own damage formulas directly (severity decaying 1/tick; venom + escalating 6/8/10.../20 every 30 ticks) since NPCs get poisoned/envenomed identically to players + mechanically - only the "where is this stored" part is new (NPCs have no varps). Unblocked + **Webweaver bow**. Toxic blowpipe and Rosewood blowpipe also needed this, but are still blocked + separately on the missing `BlowpipeAmmo` (see above) - this piece alone wasn't enough for them. +- **`BindEffectService`** (`api/combat/combat-commons/`) - a duration tracker for melee specials + that immobilize a target and/or read how long it's already bound for (`Npc.movementLocked` for + NPCs; a lazy "until" check, no engine flag exists for Player). Deliberately separate from the + pre-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. Unblocked **Zamorak godsword's Ice Cleave** (NPC side; players + still freeze via the existing spell mechanic) and **Blue Moon spear's Break Shackles** (reads/ + breaks the same tracker). +- **`ShoveStunService`** (`api/combat/combat-commons/`) - the Shove special's stun (Dragon spear, + Zamorakian spear/hasta). Verified against the wiki's own "Stun (status)" page while building this: + torka's original file had an invented one-cycle post-stun immunity that doesn't exist in the real + game ("There is no immunity and thus the stuns from Shove can be chained") - removed it entirely + rather than porting the bug forward. Scope limit, documented in the file itself: a real stun + blocks movement, attacking, eating, equipment-changing, and spellcasting, but this only drives + `Npc.movementLocked` for NPCs and exposes a boolean for other code to check - nothing currently + enforces the equivalent for player targets (no existing "block all actions" flag to hook into), so + a stunned player can still act. The knockback itself also uses a plain teleport rather than the + engine's `exactMove` primitive (a smoothed client-side slide) - `exactMove` has zero existing + usages anywhere in this codebase to confirm its timing/direction parameters against, so this + trades away that visual for something unambiguously correct instead of guessing. + +You asked directly whether this batch had gotten test coverage - it hadn't, which broke this +session's own established pattern. Went back and extracted the pure logic out of all four new +services plus the two weapons with real inline math, and wrote tests for each: `BurnStacksTest`, +`ExpiringDurationMathTest` (shared by `BindEffectService`/`ShoveStunService`), `NpcPoisonOverrideTest`, +`UnleashMultipliersTest`, `ImpactMeleeSpecialAttacksTest`. Writing the last one caught a real, +previously-unnoticed bug: Dragon warhammer's NPC defence reduction was `current * 70 / 100`, but the +wiki's own worked example (75 -> 53 -> 38 Defence across two hits) only matches `current - +floor(current * 0.3)` - the two formulas floor at a different point and disagree whenever +`current * 30` isn't a clean multiple of 100. Fixed. + +Also found and fixed two small pre-existing bugs while wiring these three specials up: +- Zamorak godsword's freeze duration was 33 ticks; the wiki is explicit it's 32 ("19.2 seconds"). +- Saradomin godsword's heal/prayer-restore was gated on `impact.damage` (the post-mitigation hit). + The wiki is explicit this is wrong: "calculated from the potential damage of a swing *before* some + types of damage immunities are applied" and "before flat armour is applied" - switched to the + already-known pre-mitigation roll, matching this session's established "real OSRS doesn't clamp + damage after the roll" pattern (this isn't just an engine-diff workaround here - it's the + wiki-documented *correct* behavior). Bandos godsword and Dragon warhammer were also switched from + gating on `impact.damage` to gating on the accuracy roll itself, per the wiki's explicit contrast + ("unlike... Bandos godsword... the dragon warhammer only needs to roll a successful hit"). + +### Missing normal-attack audit (Thunder khopesh's bug wasn't unique) + +You asked whether other converted weapons might have the same "special works, normal attack is +broken/incomplete" gap Thunder khopesh had. Checked it directly rather than guessing: spot-checked +cache animation params (`attack_anim_stance*`) for every recent/limited-release item among the +converted weapons (Bounty Hunter, Leagues, quest-reward exclusives - the same category Thunder +khopesh belongs to). Found and fixed two more real, complete gaps (same as khopesh): +- **Crimson kisten** (June 2026 Maggot King drop) - zero animation params. Fixed with the item's own + real named animations (`seq.human_weapons_crimson_kisten_attack`/`_attack_alt`), not a generic + reuse - matches the wiki's own trivia about which styles kept which animation after a post-release + hotfix. +- **Sunspear** (Blood Moon Rises quest reward) - zero animation params. Only its *special* attack has + a named animation in gameval; its normal swing reuses the generic Spear-category fallback (same + set the rune spear uses). + +And confirmed **Fang of the Hound** has the exact same passive-and-animation gap as Thunder khopesh: +wiki - "every hit... has a 5% chance to cast Flames of Cerberus... without needing to pass a second +accuracy check" - never implemented, and its cache entry is also missing animation params entirely +(same Leagues-exclusive-item pattern). Fixed both pieces the same way as khopesh: a new +`FangOfTheHoundWeapons.kt`, hardcoded animation (no cache dependency), and a +`WeaponAttackManager.rollSpellMaxHit` wrapper (same story as the `SpecialAttackManager` one added +earlier - the underlying `PlayerAttackManager` function already existed). + +Checked several more suspects and ruled them out with real evidence, not assumption: Morrigan's +throwing axe (ranged weapons only need one animation param, `attack_anim_stance1` - already present) +and Voidwaker (fully populated, matches Dragon scimitar's own values exactly) are both fine. +Statius's warhammer is missing its stance-3 slot, but warhammers only have 3 real combat styles +(Pound/Pummel/Block) - there's no 4th style to be missing an animation for, so that's not a bug. + +**Update: did the full systematic sweep.** You found Dogsword had the same bug, which prompted +checking every recently-released weapon's cache data directly instead of waiting for more reports. +Confirmed and fixed **8 items total** with a complete `attack_anim_stance*` gap (all also missing +`weaponCategory`, so all showed "Unarmed"/Punch-Kick-Block before this): Thunder khopesh (both +League and Deadman variants), Fang of the Hound, Crimson kisten, Sunspear, Dogsword (both Echo and +Deadman variants), Burning claws, Arkan blade. Fixed each with the closest real reference: Dogsword +and Burning claws both cite real sounds/animations on their own wiki pages (Burning claws literally +lists "dragonclaws_normal" as its attack sound), so those reused Dragon claws'/a generic +TwoHandedSword's real set rather than a guess. + +Checked and confirmed fine (fully populated cache data, no fix needed): Voidwaker, Morrigan's +throwing axe/javelin, Statius's warhammer, Vesta's longsword/spear, Zaryte crossbow, Blue Moon +spear, Tonalztics of Ralos, Osmumten's fang, Soulreaper axe, Eclipse atlatl, Dual macuahuitl, +Saradomin's blessed sword, Webweaver bow. + +**This is now a real, completed sweep of every recently-released weapon among the tracked 68 - not +just the "obviously similar" ones** - if another one still turns up broken, it'd be a genuine +surprise rather than an expected gap. + +**Correction after your screenshots**: Crimson kisten and Fang of the Hound were still showing +Punch/Kick/Block ("Category: Unarmed") even after the animation fix. Root cause: `weaponCategory` +itself was never set for either (I'd only fixed that for Thunder khopesh) - the combat-style tab is +rendered client-side straight from the item's cache definition, so this genuinely can't be worked +around in server code the way the animation was. Added `weaponCategory="Spiked"` (Crimson kisten) +and `weaponCategory="StabSword"` (Fang of the Hound) to their cache overrides and rebuilt - this +time killing the actual lock-holder (the osrs-mcp tool, not RSProx/the game server) proactively +first, and it went through clean on the first try. Also swapped Fang of the Hound's animation from a +generic sword swing to its own real one, `seq.human_karambit_attack` - you correctly guessed it's +the non-special counterpart to `seq.human_karambit_spec` (same swing, no swipe-up finish), and it +turned up in gameval exactly as described. + +### `BurnEffectService` unlock (resolved - 3 weapons converted) + +Built a real Burn status-effect subsystem from scratch (`api/mechanics/toxins/BurnEffectService.kt`) +since it never existed. Verified against the wiki's own "Burn" status-effect page, not just the +individual weapon pages: 1 damage every 4 ticks, a normal instance lasts 40 ticks (10 total +damage), up to 5 independent stacks active at once with the tick *frequency* staying fixed but the +*amount* per tick equal to however many stacks are currently active, and a 6th application while at +5 stacks is simply discarded. Coded as `HitType.Typeless` (not `Ranged`, despite the wiki calling it +"coded as ranged damage" for NPC-immunity purposes) specifically to match the wiki's explicit +carve-out that Protect from Missiles does **not** reduce it. Self-contained, in-memory (a +`WeakHashMap>` of remaining-tick counts per stack, self-rescheduling +via `WorldQueueList` every 4 ticks) - no buff-bar icon, no persistence across logout, and NPC +ranged-immunity/burn-severity-tier interactions aren't modeled. Same scope tradeoff as this +session's other timed-effect additions (`VestaSpearCombatImmunity`, `MorriganHamstring`). + +Also added `consumeRemainingDamage(target)` - sums and clears every active stack's remaining +damage, ends the burn entirely - needed by Eclipse atlatl's own special attack, which (per the +wiki) converts unspent burn damage into a one-off max/min hit bonus. + +This unblocked **Arkan blade, Burning claws, Eclipse atlatl** - moved out of `disabled-tier-b/`. +Arkan blade needed no other changes (`burns.apply(...)` was already correct). Eclipse atlatl also +had the fake `HitImpactHandler` bug (`queueMagicHit` doesn't have that parameter) and was missing +`SpecialAttackManager.rollMagicalRangedAccuracy` (same story as the melee equivalent earlier - +already fully implemented in `PlayerAttackManager`, just needed the wrapper). + +### Morrigan's throwing axe (resolved) + +The only missing piece was `Player.runEnergyDrainMultiplier[Until]`, which never existed anywhere in +the engine. Added `MorriganHamstring` (`api/player/hit/modifier/`) - an `AttributeKey`-based timed +flag, same shape as `VestaSpearCombatImmunity` - and hooked it into +`PlayerRunUpdateProcessor.decreaseRunEnergy()` (the one real per-tick run-energy-loss calculation) +to multiply the loss by 6x while active. Also had the fake `HitImpactHandler` bug in +`queueRangedHit`; the accuracy roll was already known synchronously, so the hamstring effect is +applied directly instead of waiting for an impact callback. + +### `PvPAreaAttackManager` unlock (resolved - 8 weapons converted) + +Turned out this needed far less new engine work than feared. `AreaMeleeTargetSelector.kt` (the +shared target-picker for every AOE melee/ranged special) was already fully written and +self-contained - it only needed a real `PvPAreaAttackManager` at +`api/combat/combat-scripts/.../org/rsmod/api/combat/player/PvPAreaAttackManager.kt` providing +`canAttack(source, target): Boolean` and `applySecondarySpecialAttack(source, target)`. Both turned +out to be thin wrappers around infrastructure that already exists for the game's *normal* PvP +combat path: +- `canAttack` reuses the existing `Set` hooks (the same wilderness-level- + range/Ferox-Enclave/would-skull-prevention checks `PvPCombatScript`'s own `attemptCombatOp`/`Ap` + already runs for a normal attack) plus the existing `Player.isValidTarget()` check. +- `applySecondarySpecialAttack` replicates what `PvPCombat`'s `applyPkVars`/`applySpecialAttackHooks` + already do for the *primary* target (skull hooks, `setPkVars`, special-attack hooks) - needed + separately because secondary AOE targets never go through `PvPCombat.attack` at all; the special- + attack file calls `manager.queueMeleeHit`/`queueRangedHit` on them directly. + +Placed in the same package/module as `PvPCombat`/`PvPCombatScript` specifically so it could reuse +the module-internal `setPkVars(target: Player)` extension function directly instead of duplicating +its pk-var bookkeeping. + +This unblocked all 8 files that only needed `PvPAreaAttackManager`: **Crystal halberd, Dinh's +bulwark, Dragon 2h sword, Dragon halberd, Dragon crossbow, Rune thrownaxe, Thunder khopesh, Vesta +spear** - moved out of `disabled-tier-b/` along with `AreaMeleeTargetSelector.kt` itself. + +Three real bugs found and fixed while moving these in (none are `PvPAreaAttackManager`'s fault - +pre-existing issues in torka's original files that only a real compile could surface): +- **`HitImpactHandler` isn't a real type anywhere in this engine.** `Dinh's bulwark` and `Rune + thrownaxe` both referenced it (`queueMeleeHit`/`queueRangedHit impactHandler = HitImpactHandler { + ... }`), but neither function actually has an `impactHandler` parameter - grepping the whole repo + found `HitImpactHandler` used only inside still-disabled torka files, never a real declaration. + Same fix as the rest of tonight's Tier B work: the information the handler wanted (whether the + hit actually did damage / whether the accuracy roll succeeded) is already known synchronously + before the hit is queued, since real OSRS doesn't clamp damage after the roll - so both became + plain synchronous checks (`if (damage > 0)` for the shield-bash drain, `if (roll.successful)` for + continuing Rune thrownaxe's ricochet chain) instead of a callback. **Worth checking for the same + pattern in the remaining 14 disabled files before assuming any of them "just needs + `HitImpactHandler`" wired up** - it may not exist to wire up at all. +- **Vesta spear's Spear Wall anim/spotanim used raw cache ints** (`player.anim(8184)`, + `spot = 1627`) against functions that only take `String` RSCM names. Same situation as Crimson + kisten earlier - these two cache-native effects don't have named RSCM aliases in this revision - + fixed with `RSCM.getReverseMapping(RSCMType.SEQ/SPOTANIM, id)`, the same reverse-mapping pattern + already used throughout this file family for cache-only spotanims/projanims. +- **Vesta spear's melee-only immunity was wrong per the current wiki.** Built as + `VestaSpearCombatImmunity` (not the `VestaSpearMeleeImmunity` name torka's file imported) because + the wiki is explicit the effect blocks *both* melee and ranged damage ("the user becomes immune to + melee and ranged attacks for 8 ticks" - ranged immunity was added in a later real-game update). + Wired into `StandardPlayerHitModifier` (checked before the protection-prayer/Power-of-Death + branches, same as `adminGodMode`) since it has to block *all* incoming melee/ranged, not just + special-attack hits. + +## Bugs found live-testing the newly-converted Tier B weapons + +- **Ancient godsword's mark spotanim** - was `target.spotanim` (enemy-anchored), which you spotted + as looking flat/static instead of following the sword like the real wiki screenshot. Same + "combat-slot weapon effects are attacker-anchored" pattern as Dragon claws - switched to bare + `spotanim`. Still looked slightly off after that (floating above/behind the blade); dropped + `height` from 96 (copied blindly from Dragon claws) to 48. **You confirmed this looks good.** + Flagged Armadyl godsword as needing the same careful re-check, since its "looks good" was a much + quicker pass than this one got. +- **Every enchanted bolt effect (all 10, on Armadyl and Zaryte crossbow) was missing its own + activation spotanim entirely** - Ruby's Blood Forfeit was the one you found first, but tracing it + fully turned up the whole set (wiki's own effect names, cross-referenced to `spotanim.xbows_*` + cache assets - Opal "Lucky Lightning", Jade "Earth's Fury", Pearl "Sea Curse", Topaz "Down to + Earth", Sapphire "Clear Mind", Emerald "Magical Poison", Ruby "Blood Forfeit", Diamond "Armour + Piercing", Dragonstone "Dragon's Breath", Onyx "Life Leech"). All target-anchored per their wiki + descriptions ("strikes the target", "appears around the target", etc.) - including Ruby, which + turned out to belong on the *target* (where the blood is drawn from), not the shooter, despite + the "sacrifice" framing; you caught that live. Added `spotanim` as a field directly on the + `ArmadylEnchantedBolt` enum and wired one shared call into `applyEffect`/`zaryteApplyEffect` so + every bolt gets it without duplicating the call per-effect. + - Also fixed: Ruby's self-damage used `player.statSub("stat.hitpoints", ...)`, which silently + changes the stat with **no hitsplat at all** - switched to `takeInstantHit(...)`, matching Dual + macuahuitl's already-correct pattern for the same kind of self-damage. Checked the rest of + tonight's weapons for the same `statSub`-on-hitpoints anti-pattern - this was the only instance. + - **Found, not fixed (same known gap as the deferred Tier B poison/burn work):** Jade (freeze) + and Emerald (poison) currently do nothing against NPCs - `CombatEffects.freeze`/`poison` only + accept `Player` targets, and NPC poison specifically needs the same `NpcPoisonEffectService` + that Toxic blowpipe/Rosewood blowpipe/Webweaver bow are already blocked on. Not a new gap, just + confirmation it extends here too. + - Not yet live-tested. +- **Vesta's longsword's Feint - `::maxhit` bug, same category as Dragon claws/Dual macuahuitl.** + The damage roll (`random.of(range)`) never checked `player.adminMaxHit`, so the cheat silently + did nothing for this weapon's spec - same root cause as every other maxhit bug tonight. Extracted + a `resolveDamage` function (same pattern as everywhere else) and added the check, with 3 new test + cases in the existing `VestaLongswordFeintDamageTest.kt`. Not yet re-tested live. + +## Fixed (real bugs found and corrected) + +- **Armadyl godsword** - `spotanim` had no receiver; switched to `target.spotanim`. You live-tested + this after the fix and confirmed it looks good - kept as target-targeted. **Live-confirmed, but + see the Ancient godsword entry below - that "looks good" was a quick check, not a careful + wiki-screenshot comparison, and AGS's own target-anchored spotanim turned out wrong under that + closer look. Armadyl godsword deserves the same re-check before fully trusting it.** +- **Abyssal bludgeon** - same fix (`target.spotanim`), originally based on torka's own Discord bug + report that the Penance visual played on the attacker instead of the enemy on his branch. You + live-tested it after the Dragon-mace-class revert below and confirmed it's on the enemy, correct. + **Live-confirmed.** +- **~~Dragon dagger, Dragon longsword, Dragon mace, Dual macuahuitl, Granite hammer, Osmumten's + fang, Rune claws, Soulreaper axe~~ - REVERTED, this was a wrong bulk fix.** I generalized from + torka's one specific Abyssal bludgeon complaint into a blanket "self-targeted spotanim = + always a bug" rule and applied it across this whole class without checking each weapon + individually. You live-tested it and the effect was landing on you (wrong) for this group even + after the "fix." Real counter-evidence for the revert: our own verified, live damage-tested + Dragon claws uses the exact same bare `spotanim(..., slot = constants.spotanim_slot_combat)` + pattern and has never had an animation complaint; the standard (non-special) ranged combat code + in `PvNCombat.kt` uses the identical bare pattern for its launch/muzzle-flash effect; and an + earlier, already-committed version of Dragon mace (before torka's contribution) also used bare + `spotanim` - which lines up with you not remembering an issue there before. Conclusion: + `spotanim_slot_combat` without a receiver is a weapon-effect overlay on the *attacker's own* + animation, not a target impact effect - reverted all 8 back to bare `spotanim(...)`. **You + re-tested Abyssal bludgeon, Dragon dagger, Dragon mace, etc. after the revert and confirmed + bludgeon lands on the enemy and the rest are back on the attacker/weapon as expected - all + live-confirmed except Soulreaper axe, which isn't reachable in-game yet (Soul Stacks don't + build up currently, unrelated to this fix) and Osmumten's fang/Rune claws which you didn't + explicitly call out but should still be checked.** +- **Granite hammer height** - separately from the receiver revert above, you flagged the effect as + "a little too high" compared to the real wiki reference screenshot (which shows it low, basically + on the weapon) even with the correct (attacker) receiver. `height = 96` was blindly copied from + Dragon claws when this file was written; the engine's own default is `height = 0` (no vertical + offset). Dropped to `0`. This is a visual-tuning guess, not something verifiable from source like + the receiver was - **needs your re-test to confirm**, not yet live-confirmed. +- **Magic shortbow** - a second issue on the same line as the animation bug: the "launch" spotanim + (the muzzle-flash effect, which - unlike the others above - correctly belongs on the *shooter*, + not the target) was wrongly given a `target.` receiver by my own first-pass fix, and also had a + nullable/non-null type mismatch. Both corrected; matches the same pattern already proven correct + in `PvNCombat.kt`'s standard ranged attack handling. +- **Magic bow** - was using the standard ranged max-hit formula, but the wiki documents Powershot + as a custom formula that ignores gear ranged strength, prayers/void, and Slayer helmet(i) - + only visible Ranged level and the ammo's own ranged strength matter. Reused the already-correct + implementation from Magic shortbow's own Snapshot special (same formula, same constants). +- **Dark bow** - Descent of Darkness (regular arrows) had no upper damage cap at all + (`5..Int.MAX_VALUE`). Wiki confirms both variants cap at 48 (`5..48` / `8..48`). Fixed. +- **Soulreaper axe (Behead)** - two real bugs: + 1. Accuracy and damage were both boosted by the same 6%-per-stack figure. The wiki is explicit: + 12% accuracy per stack, 6% damage per stack - different numbers. Fixed with separate + multipliers. + 2. The wiki also documents a *minimum* damage floor (30% of the boosted max hit at 5 stacks), + which the standard damage-rolling function has no way to express (it only rolls `1..maxHit`). + Implemented a custom roll for this, following the same pattern as Dragon claws' `rollRange` + (including respecting `::maxhit` for testability). +- **Dual macuahuitl (Blood Infusion) - `::maxhit` bug, same category as Dragon claws.** You + reported the spec (19,10=29 total) looking weaker than a maxed regular attack (17,17=34 total) + and asked if it might just be the display. Traced it fully: real Dual macuahuitl's *normal* + attack already deals 2 hits, each independently rolled at HALF the weapon's full max hit + (confirmed in the actual normal-attack file, `content/other/special-weapons/.../ + DualMacuahuitlWeapons.kt`, which calls `rollMeleeDamage` twice with `maxHitMultiplier = 0.5`) - + so "17,17" is two half-hits of a full 34 max. The special's own `normalMax` variable is that same + *full*, un-halved value, and with the existing 25%-total-boost math that gives valid per-hit + ranges of roughly 4-21 and 5-21 - 19 and 10 both fall inside those, so **the formula itself was + never wrong**; 29 total is just an unlucky legitimate roll, not a bug in the multiplier logic. + The actual bug: the special's custom `rollDamage` lambda never checked `player.adminMaxHit`, so + `::maxhit` silently did nothing here (identical root cause to the original Dragon claws bug + fixed earlier this session). Fixed - now forces `range.last` when the cheat is active, so you + should get a real maxed total noticeably above 34 on retest. +- **Soulreaper stack decay** (shared helper, not a weapon file itself) - two bugs found while + cross-checking Soulreaper axe: + 1. It was healing the player 8 HP for every stack lost to *passive decay* (30s of not attacking + with the axe). That heal belongs only to actually using Behead to consume stacks - decay + should be a pure loss. Removed. + 2. The decay interval was 20 cycles (12s); the wiki says 50 ticks (30s). Fixed. + +## Real feature gap found and fixed: Soul Stacks were never generated + +You asked directly whether Soul Stacks were ever implemented correctly - traced it and confirmed: +no. What existed before tonight: Behead (consuming stacks) and decay (losing stacks after 30s of +inactivity), both of which I'd touched earlier this session, plus the Strength bonus from *held* +stacks (`MeleeMaxHitOperations.calculateEffectiveStrength` already read `varp.soulreaper_stacks` +and applied +6% Strength per stack). What was missing: the actual *gain*. No file anywhere +registered a normal-attack handler for the axe at all - it fell through to the generic melee +weapon path, which has no notion of stacks. So stacks could only ever go down, never up. Not +something I broke; a pure gap from before I started, presumably never in torka's patch either. + +Wiki ("Soul stacks" section on the axe's own page) is explicit: a stack is generated on *every* +attack with the axe (even a miss) until five are held, applied *after* that swing's own damage is +calculated (so the swing that generates a stack doesn't benefit from the bonus it just earned). +Implemented as a new `content/other/special-weapons/.../melee/SoulreaperAxeWeapons.kt`, registered +in `MeleeWeaponsModule.kt`: an ordinary Crush attack that, after `queueMeleeHit`, increments the +stack (capped at 5 via a small pure `SoulreaperStackGain.nextStackCount` - tested in +`SoulreaperStackGainTest.kt`) and resets the decay timer. Added the `api.mechanics.toxins` gradle +dependency to `special-weapons/build.gradle.kts` for this. Compiles and tests clean - **not yet +live-tested**, since this is brand new code, not a refactor of something already confirmed working. + +## Ancient godsword's full history (now fully converted, in the live source set) + +- Earlier tonight: the "apply the mark" `HitImpactHandler` gate (on landing the initial hit) + simplified to a synchronous check, since real OSRS doesn't clamp damage after the roll. + - Also checked and **did not change**: I initially suspected `HitType.Typeless` was wrong here + (thought Protect from Magic should reduce the delayed hit), but the primary, current wiki page + is explicit that this damage is deliberately typeless and unaffected by Protect from Magic - + a secondary source I glanced at first was outdated/beta info. Caught before making the change. +- Later tonight (Tier B batch): the *delayed heal* `HitImpactHandler` use (8 ticks later) converted + to the world-queue re-schedule pattern - the file moved out of `disabled-tier-b/` entirely. +- **You reported the mark's spotanim looking wrong** - flat/static, planted on the ground, not + following the sword like the real wiki screenshot shows. The code had `target.spotanim` (enemy- + anchored), which is right for an *impact* effect but wrong for a *weapon-swing* effect like this + one - same "combat slot spotanim should be attacker-anchored" pattern established earlier tonight + for Dragon claws etc. Reverted to bare `spotanim` (attacker-anchored). **Not yet re-tested.** + Heal caps (15%, capped 25 NPC / 15 player) were already correct. + +## Verified correct, no changes needed + +- **Dragon claws** - ours (already independently verified earlier), kept over torka's version + (his used a different "roll a total, divide by 2" algorithm that doesn't look equivalent to the + wiki's own worked example). +- **Abyssal dagger** - 25% accuracy, 15%/5% damage reduction (regular/imbued), single accuracy + roll for both hits, slash defence - all match exactly. +- **Dragon battleaxe** - 10% drain to Attack/Defence/Ranged/Magic, Strength boost = 10 + + (drained/4) - exact match. +- **StatBoostSpecialAttacks** (dragon/infernal/trailblazer/crystal axe, harpoon, pickaxe + + Excalibur) - all the +3 skill boosts and Excalibur's +8 Defence match. +- **Dragon candle dagger** - trivial always-0-damage cosmetic special, correct as written. +- **Ballista (light/heavy)** - 25% accuracy + damage, matches your own live test. +- **Dragon knife** - two independent ordinary throws, explicitly no bonus per the wiki. +- **Dragon thrownaxe** - 25% accuracy only, guaranteed next-tick attack. +- **Granite maul (all variants incl. ornate handle)** - Quick Smash is a plain Crush attack with + no accuracy or damage bonus at all ("unlike most special attacks, Quick Smash does not increase + accuracy" - wiki); code uses `accuracyMultiplier = 1.0, maxHitMultiplier = 1.0`. The 60%/50% + energy cost split between base/ornate-handle variants is read from cache data per-obj, not + hardcoded. Matches. +- **Noxious halberd (Virulence)** - self-buff, no target at all (cures your own poison/venom). + Its bare `spotanim` call was *already* correct - my first-pass bulk fix wrongly "corrected" this + one too, caught by a real compile error (`target` doesn't exist in this function) and reverted. +- **HalberdSpecialVisuals, RangedSpecialAttackEffects** (shared helpers) - simple, correct + utilities, nothing weapon-specific to verify. + +## Newly converted from Tier B tonight (22 weapons, not yet live-tested) + +All confirmed to compile and match their wiki mechanic, but this is brand-new code (not a refactor +of something already proven) - needs a first live test, same as the Soul Stack gain feature. Full +list: Ancient godsword (mark-gate simplified, delayed heal still uses the world-queue re-schedule +pattern), Ancient mace, Abyssal whip, Barrelchest anchor, Brine sabre, Demonbane (Darklight/ +Arclight/Emberlight), Dogsword, Dragon scimitar, Dragon sword, Elder maul, Infernal tecpatl (reuses +Dragon claws' cascade math), Nightmare staff, Saradomin sword, Staff of the dead, Statius +warhammer, Vesta longsword, Voidwaker, Armadyl crossbow, Morrigan's javelin, Seercull, Tonalztics +of Ralos, Zaryte crossbow. + +See "Tier B breakthrough" above for the still-deferred 26 (`disabled-tier-b/`). + +## More converted since (12 weapons, not yet live-tested) + +Four "hard" one-off weapons turned out to need far less than their original categorization implied +- in each case the actual roll/accuracy function already existed somewhere in `PlayerAttackManager`/ +`AccuracyFormulae`, just missing a `SpecialAttackManager`-level wrapper (or, for Sunspear, missing +nothing at all - just a different way of expressing what already existed): +- **Crimson kisten** - the missing `dev.openrune.CrimsonKisten` constants object never existed; + the item *does* have a real RSCM alias in this revision (`obj.crimson_kisten`) despite torka's + comment claiming otherwise, but the cache genuinely lacks a `param_1564` special-energy param + (hence the raw-`Int`-energy `registerMelee` overload). Fixed by inlining the real constants + directly instead of the missing external object. +- **Sunspear** - "rolls with exactly 70% of its maximum accuracy" needed no new engine function at + all: this engine's attack roll is already deterministic (no RNG) and scaled by `multiplier` before + the single random hit/miss sample, so it's just the standard `rollMeleeAccuracy` with a + conditional `0.70` multiplier instead of a separate "fixed roll" mechanism. +- **Saradomin's blessed sword** - only needed `SpecialAttackManager.rollMagicalMeleeAccuracy` + wrapping the already-fully-implemented `PlayerAttackManager.rollMagicalMeleeAccuracy` (found this + out the hard way - first added a duplicate implementation directly in `PlayerAttackManager` and + got a "conflicting overloads" compile error). +- **Fang of the Hound** - same story for `SpecialAttackManager.rollSpellMaxHit`, wrapping the + already-implemented `PlayerAttackManager.rollSpellMaxHit`/`calculateSpellMaxHit`. Confirmed + against the wiki: Flames of Cerberus, base max hit 10, guaranteed cast on a landed special hit, no + separate accuracy check. + +Plus the 8 `PvPAreaAttackManager` weapons: **Crystal halberd, Dinh's bulwark, Dragon 2h sword, +Dragon halberd, Dragon crossbow, Rune thrownaxe, Thunder khopesh, Vesta spear** - see the +"`PvPAreaAttackManager` unlock" section above for what each needed and the bugs fixed while moving +them in. + +## Test files + +None of these files had testable logic separated from the live `SpecialAttackManager` (which has +no test harness anywhere in this codebase), except Dual macuahuitl (torka's own extraction). Same +pattern applied to the rest as it's used: pull the weapon-specific formula into a small pure +object/function that takes its inputs as plain params instead of a `ProtectedAccess` receiver, and +unit test that - the manager-wiring glue stays untested, same as everywhere else. + +**Done (batch 1 - genuine custom math, not just a constant):** +- **Dragon claws** (`DragonClawsDamageTest.kt`) - the four-hit cascade tested against all four wiki + worked examples (35-17-8-9, 0-30-15-16, 0-0-22-23, 0-0-0-46), all four hit-range boundaries, all + four miss-cascade sympathy patterns, and `resolveRange`'s `::maxhit`/edge-case handling. +- **Soulreaper axe** (`SoulreaperAxeDamageTest.kt`) - accuracy/damage multiplier formulas, the 30%- + at-5-stacks minimum floor, and the accuracy-fail/maxhit/random resolveDamage paths. +- **Soulreaper stack decay** (`SoulreaperStackDecayTest.kt`, in `api/mechanics/toxins` - this is + where the class actually lives) - the 50-cycle interval and due/not-due boundary, with the timing + math split out from the `Player`-attached state so it doesn't need a real player to test. +- **Magic bow** (`MagicBowDamageTest.kt`) - the Powershot formula checked against 3 hand-computed + cases (confirming it truly ignores everything but ranged level and ammo strength) plus the + maxhit/random resolveDamage paths. +- **Dark bow** (`DarkBowDamageTest.kt`) - both variants' range/multiplier constants, and the + floor/cap clamping behavior (below floor, above cap, inside range, and a miss staying 0). +- **Dual macuahuitl** (existing `DualMacuahuitlDamageTest.kt`, extended) - added 3 cases covering + tonight's `::maxhit` fix (was previously silently ignoring the cheat, same root cause as the + original Dragon claws bug); the fix itself required extracting the inline `when` block into a + testable `resolveDamage` function first, same pattern as everywhere else here. + +**Batch 2 done.** Went through every remaining Tier A weapon (Abyssal dagger, Armadyl godsword, +Dragon mace, Dragon longsword, Granite hammer, StatBoost weapons, Ballista, Dragon knife, Dragon +thrownaxe, Rune claws, Granite maul, Dragon candle dagger, Noxious halberd - Abyssal bludgeon, +Dragon battleaxe, and Osmumten's fang already had tests from earlier). Most turned out to be pure +"pass a literal constant to the shared manager" with nothing to regression-test beyond what's +already wiki-verified above - extracted and tested only the ones with genuine derived logic: +- **Abyssal dagger** (`AbyssalDaggerTimingTest.kt`) - the second hit's delay differs by target type + (1 tick vs players, 2 vs NPCs). +- **Ballista** (`BallistaAnimationTest.kt`) - the 4-way animation choice (ornamented x target type). +- **Dragon knife** (`DragonKnifeVariantTest.kt`) - poisoned-variant detection from the item name. +- **Granite maul** (`GraniteMaulStyleTest.kt`) - the combat-stance-to-attack-style mapping. + +The rest (Armadyl godsword, Dragon mace, Dragon longsword, Granite hammer, StatBoost weapons, +Dragon thrownaxe, Rune claws, Granite maul's own damage roll, Dragon candle dagger) are flat +manager calls with literal multipliers - no test added, nothing there to catch a regression that +the wiki verification above didn't already confirm. Noxious halberd's toxin-priority branch +(venom > poison > neither) depends on live `PlayerVenom`/`PlayerPoison` state rather than pure +math, so it wasn't a good extraction candidate either. + +## Live-test fixup round: Dogsword's real animation, height tuning, and a real energy crash + +**Dogsword's animation was cache-correct but visually wrong.** The earlier `items.toml` fix (generic +`TwoHandedSword` category swing, `seq.human_dhsword_slash`/`_chop`) matched what a real plain 2h +sword uses and fixed the "Category: Unarmed"/Punch-Kick-Block bug, but looked like a plain sword +swing, not a godsword's. Real Bandos/Saradomin/Zamorak godswords use different raw animation IDs +(`7045`/`7054`/`7055`, sounds `3847`/`3846`, confirmed via `cache_search` on the real Bandos +godsword) that have **no named RSCM alias** in this cache revision (`cache_search type=seq +id=7045` returns an empty `debugName`). Since `items.toml` params need string names, built +`content/other/special-weapons/.../melee/DogswordWeapons.kt`: a normal-attack `WeaponMap` entry for +`obj.echo_godsword`/`obj.deadman_dogsword` that plays the real animation via +`RSCM.getReverseMapping(RSCMType.SEQ, rawId)` at runtime instead of a cache param - same technique +already used for Vesta spear/Crimson kisten's specials. `weaponCategory` in `items.toml` is still +what fixes the combat-tab UI and stays necessary; the code-side swing now overrides the visual on +top of it. + +**Found and fixed two duplicate `[[item]]` blocks in `items.toml`** while working the Dogsword fix: +`obj.bone_claws` and `obj.echo_godsword` each had two separate override blocks (one pre-existing, +one added by an earlier fix pass tonight, without noticing the first). The loader apparently merges +duplicates by key rather than erroring, so nothing broke, but this needed cleaning up regardless - +merged each pair into one block (keeping `tradeable = false` and `param.attackrate` from +`echo_godsword`'s original, pre-existing entry) and deleted the redundant leftovers. + +**Height tuning round 2** (same "blindly copied `height = 96` from Dragon claws" pattern as before, +still only fixed on files the user explicitly confirmed too high via live testing - see the earlier +"systemic `height = 96`" note; still not a blanket fix across all remaining files): Bandos godsword, +Saradomin godsword, Zamorak godsword, Dragon warhammer (all four in `ImpactMeleeSpecialAttacks.kt`), +Elder maul (both the launch and target-impact spotanim), Soulreaper axe - all `96` to `48`, each +with the same "unverified visual-tuning guess" disclaimer comment. Confirmed correct and +**deliberately left untouched**: Dragon dagger, and Dragon hasta's own Shove/Unleash spotanims +(user specifically called these out as the one exception that isn't too high, unlike everything +else) - plus whichever "mace" the user tested (ambiguous between Dragon mace and Ancient mace in +their message; left both alone rather than guess wrong on a confirmed-correct file). + +**Real crash fixed: "Not enough special energy to take" disconnecting the player.** Reported live +against Dragon spear's Shove (`PvNCombat.attackMelee:77` → `activateMeleeSpecial` → +`SpecialAttackEnergy.takeSpecialEnergy` threw `IllegalArgumentException`, twice, ~16s apart, same +test player). Investigated and ruled out: Dragon spear's real registered cost is `250` (25%, via +cache enum 906/`sa_energy_requirements`, confirmed identical for `obj.dragon_spear` id=1249 and +both `obj.zamorak_spear`/`obj.zamorak_hasta`, all fed through the same `Shove` instance) - well +above the `< 10` "specialized/self-managing" threshold, so `activateMeleeSpecial`'s normal +`hasSpecialEnergy` pre-check does run for it; `Shove` doesn't self-manage energy at all and never +touches `specialEnergyVarp`; no duplicate registration or item-id collision exists for +`obj.dragon_spear` itself. Given the pre-check and the deduction are separated only by `Shove`'s own +body (which never spends energy), the only way this throws is if the player's special energy +changed between those two points - couldn't pin the exact interleaving (a genuine tick-overlap race +under rapid clicking is the most plausible candidate, given both crashes were immediately +reproducible), so rather than keep chasing an exact trigger, **hardened all three +`activateXSpecial` functions in `api/combat/combat-scripts/.../PlayerCommons.kt`** (melee, ranged, +magic - identical shape in all three) to re-validate `hasSpecialEnergy` immediately before calling +`takeSpecialEnergy`, instead of taking unconditionally whenever the special returned `true`. This +closes the entire crash class regardless of its root cause: worst case now is a special executing +for free once, never an uncaught exception mid-tick. `compileKotlin`/`compileTestKotlin`/`test` all +exit 0; cache rebuilt clean; server restarted with zero boot errors. + +## Live-test fixup round 3: Dogsword's holding stance, height=0, Fang's proc effect + +**Dogsword's swing animation was right but the idle/wield "holding" pose wasn't.** Root cause: +`weaponCategory="TwoHandedSword"` matches a generic 2h sword's combat-tab text ("2h sword") but +`WeaponCategory.kt` has a distinct `GodSword` entry (id 23) with its own note that equipping one +re-sets a client-side wield-stance varp (357) from `10` to `23` via the item's own op script - real +godswords use `GodSword`, not the generic category, even though both display identically in the +combat tab. Switched `obj.echo_godsword`/`obj.deadman_dogsword` to `weaponCategory="GodSword"`. + +**Height=48 (this round's earlier guess) was still too high per live feedback** - dropped to `0` +(ground level) on every file touched in the two height-fixing rounds tonight: Arkan blade, Burning +claws, Morrigan's throwing axe, Bandos/Saradomin/Zamorak godswords, Dragon warhammer, Elder maul +(both spotanim), Soulreaper axe. Still the same discipline as before - only files already flagged +live, not a blanket sweep of the remaining ~30 files still at `height = 96`. + +**Fang of the Hound's Flames of Cerberus passive** (the on-hit proc from its *normal* attack, not +its special) had the same blind `height = 96` on the spotanim it plays on the target - dropped to +`0` per the same live feedback. + +Compiled clean, cache rebuilt, server restarted with zero boot errors. + +## New feature: melee weapon poison ((p)/(p+)/(p++) daggers, spears, hastae) - was entirely unbuilt + +Reported live: Dragon dagger(p)'s Puncture special appeared to poison the training dummy +*instantly*, unlike Webweaver bow's own poison (already fixed this session, correctly delayed 18s). +Investigation found something more basic than a second instant-hit bug: **no code anywhere applied +weapon poison for melee "(p)" weapons at all.** Grepped every poison call site in `api/` and +`content/` - the only callers of the poison system were the boss-effect DSL, Zaryte/Armadyl +crossbow specials, Webweaver bow's special, and Noxious halberd's normal attack. `items.toml` has +no poison-chance/severity param on any dagger/spear/hasta `(p)` variant, and +`DragonDaggerSpecialAttack.kt` itself has zero poison code. So the "instant splat" wasn't a second +instance of the already-fixed bug - it was something else entirely (unconfirmed; possibly a +misread of Puncture's own two close-together damage hits). Whatever it was, real weapon poison for +melee weapons was simply missing content, confirmed via the wiki (`Weapon poison`, +`Weapon poison(+)`, `Weapon poison(++)`): melee attacks apply poison at a 25% chance (1/4) on any +successful hit, dealing 4/5/6 initial damage per tier. + +Built it: **`WeaponPoisonEffect`** (`api/mechanics/toxins/`) rolls the chance and, on success, +calls the already-existing `PlayerPoison.tryPoison`/`NpcPoisonEffectService.apply` - no new damage +math needed, since both were already fixed earlier this session to start the 30-tick recurring +timer instead of hitting instantly. Tier is read directly off the weapon's own RSCM alias suffix +(`_p`/`_p+`/`_p++`, exposed via `ItemServerType.internalName`) rather than a new `items.toml` param +per item - the real cache already carries this consistently across ~90 poisoned melee weapon +variants (bronze through dragon daggers/spears, keris, bone dagger, abyssal dagger, etc.), so +covering all of them needed zero toml edits. Wired into three call sites: `PvNCombat.attackMelee` +and `PvPCombat.attackMelee`'s generic fallback path (covers every dagger/spear/hasta `(p)` variant +that doesn't have its own dedicated `WeaponMap`, which today is all of them), plus +`DragonDaggerSpecialAttack.kt`'s two Puncture hits directly (specials aren't exempt from weapon +poison in real OSRS - each of Puncture's two hits rolls independently). Added +`implementation(projects.api.random)` to the toxins module's `build.gradle.kts` (previously +unused there). `compileKotlin`/`compileTestKotlin`/`test` all exit 0 project-wide; cache rebuilt +(no toml changes needed for this part); server restarted clean. + +**Deliberately out of scope for this pass**: ranged poisoned ammo (arrows/bolts/darts/javelins/ +knives `(p)`) - the tier lives on the *ammo* item, not the weapon, and consumption runs through +`RangedAmmoManager`'s separate ammo-detraction path instead of the melee fallback above. Same +25%/12.5% chance split and 4/5/6 vs 2/3/4 damage split per the wiki, just a different, larger hook +point - not built yet. + +## Ranged weapon poison (arrows/bolts/darts/javelins/knives (p)) - built + +The deferred half of the melee weapon poison work above. Added `WeaponPoisonEffect. +rollOnRangedHit(source, target, ammo, damage)` - same wiki-verified math (12.5% chance / 1-in-8, +initial damage 2/3/4 per tier), reusing the already-existing `WeaponPoisonTier.rangedDamage`/ +`RANGED_CHANCE_DENOMINATOR` values that were defined but unused since the melee-only pass. Tier +resolution is identical to the melee path (RSCM alias suffix), just applied to whichever item was +actually consumed as ammo - the quiver item for bows/crossbows, or the wielded item itself for +thrown weapons (darts/knives/javelins), matching how `PvNCombat`/`PvPCombat`'s own generic ranged +fallback already resolves `weaponType` for ammo validation. Wired into that same fallback (covers +every standard bow/crossbow/thrown weapon using poisoned ammo automatically) plus +`DragonKnifeSpecialAttack.kt`'s Duality special specifically. + +**A second "half-wired" file found**: Dragon knife's special already had real poison *detection* +(`DragonKnifeVariant.isPoisoned`, checking the item's display name for "(p" to pick a themed +travel spotanim/animation) but never actually called into the poison system at all - the visual +distinction existed with no mechanical backing. Added `poison.rollOnRangedHit(...)` after each of +Duality's two independently-thrown knives, same as Dragon dagger's Puncture got for melee. This is +the second time this exact pattern (a weapon "looks" poison-aware but the real poison call was +simply never added) has turned up this session - worth checking any other weapon with its own +poison-flavored visual branching for the same gap. + +Added ranged-damage assertions to the existing `WeaponPoisonTierTest`. `compileKotlin`/ +`compileTestKotlin`/`test` all exit 0 project-wide; no cache rebuild needed (pure Kotlin); server +restarted clean. + +## Eclipse atlatl: real ammo-category bug found, deeper gaps documented (not fixed) + +User reported the Eclipse atlatl "doesn't work at all" for normal attacks - the special +(`EclipseAtlatlSpecialAttack.kt`) was already fully built and registered, so this was specifically +about the weapon's ordinary ranged attack. Root cause, confirmed via `items.toml` + wiki + a real +category id lookup: `obj.eclipse_atlatl`/`obj.br_eclipse_atlatl` had `weaponCategory="Bow"` but no +`param.required_ammo` override, so ammo validation (`RangedAmmunition.validateArrows`) fell back to +`category.arrows` - meaning it demanded real arrows in the quiver instead of the atlatl's actual +ammo, `obj.atlatl_dart` (id 28991, real category `category.atlatl_dart` = 1915, confirmed via +`category.rscm`). Any player without arrows equipped (i.e. everyone actually using atlatl darts as +the wiki describes) would always get "There is no ammo left in your quiver." Fixed: added +`"param.required_ammo"="category.atlatl_dart"` to both item entries. Verified in the rebuilt cache +directly (`cache_search` on item 29000 shows `params={..., 65415=1915}`, and `65415` is confirmed +as `required_ammo` in `param.rscm`). + +**Also needed a second fix after the ammo change**: the real ammo item, `obj.atlatl_dart`, had no +`param.proj_travel` at all (no flying-dart spotanim), so `PvNCombat`/`PvPCombat`'s generic ranged +fallback bailed with "You are unable to fire your ammunition." the moment ammo validation passed. +Added `"param.proj_travel"="spotanim.vfx_atlatl_projectile_01"` (the real atlatl dart travel +effect, found via `gameval_search table=spotanim query="atlatl"`). Confirmed live: normal attacks +and the special both fire correctly now. + +**Max hit formula: fixed.** Per the wiki, the atlatl's max hit is uniquely based on the player's +**melee Strength level/strength bonus**, not ranged - "a unique mechanic among ranged weapons" - +while accuracy still rolls off ranged bonus/level as normal. Confirmed via source read that +`PvNRangedMaxHit`/`PvPRangedMaxHit.computeModifiedDamage` (the single shared formula both the +special's `manager.calculateRangedMaxHit` call and the normal-attack fallback's +`manager.rollRangedDamage` → `rollRangedMaxHit` → `calculateRangedMaxHit` chain both go through) +always pulled `bonuses.rangedStrengthBonus(source)` with no override for any weapon. Fixed with one +conditional branch per formula class (`EquipmentChecks.isEclipseAtlatl(source.righthand)`), +matching the exact style already used there for Twisted bow/Dragon hunter crossbow/etc. one-off +weapon behavior. New `EclipseAtlatlMaxHit.computeBaseDamage` (`api/combat/combat-formulas/.../ +maxhit/ranged/`) reuses `PlayerMeleeMaxHit.calculateEffectiveStrength`/`calculateBaseDamage` +(literally identical formula shape to the ranged version, just different inputs) with the wiki's +other two atlatl-specific quirks folded in: melee strength prayers (Piety etc., not ranged ones) +and, per the wiki's own trivia, void bonus reads the *ranged* void helm rather than the melee one +even though the rest of the formula is melee-shaped. No melee combat style exists for this weapon +(only Accurate/Rapid/Longrange, all ranged-style), so the strength side uses the flat `+8` baseline +every style implicitly gets - no style grants a strength bonus here. Single fix point covers both +the special and any normal attack, since they share the same formula chain - no separate +`WeaponMap` needed. Not modeled (real but more niche per the wiki: "uses the melee bonuses from +the slayer helmet and salve amulets"): those two amulet/helm procs still apply their *ranged* +percentage in `RangedMaxHitOperations.modifyBaseDamage` for this weapon instead of their melee +one - a smaller, separate gap, not fixed this pass. + +**Set effect (20% burn chance): fixed.** Correctly pointed out live: the special's own "consume +remaining burn damage" bonus can never do anything for a solo player without this - there was +simply nothing else applying Burn from the atlatl itself. Built `EclipseAtlatlBurnEffect` +(`api/mechanics/toxins/`), same shape as `WeaponPoisonEffect`: on a successful ranged hit, if the +full Eclipse Moon set (helm/chestplate/tassets/atlatl) is worn, roll 20% (`randomBoolean(5)`) and +call the already-existing `BurnEffectService.apply`. Wired into the same `PvNCombat`/`PvPCombat` +ranged-attack fallback as the max hit fix, right after `queueRangedHit` - deliberately *not* wired +into the special itself, since the special's whole design is to consume burn built up between +casts, not generate more (would be self-consuming and pointless). Also deduplicated the +full-set-worn check: it used to be a private extension in `EclipseAtlatlSpecialAttack.kt`; moved +to `EquipmentChecks.isEclipseMoonSet(helm, top, legs, weapon)`, matching the existing +`isDharokSet`/`isToragSet`-style pattern in that file, so both the special and the new passive +share one definition. Full project `compileKotlin`/`compileTestKotlin`/`test` all pass; no cache +rebuild needed (pure Kotlin); server restarted clean. + +## Test coverage: two pure-math functions extracted and covered + +Added `WeaponPoisonTierTest.kt` (suffix-detection logic for the new `WeaponPoisonEffect` above, +including a regression guard against a name that merely ends in a literal "p" without being a real +poison suffix, e.g. `iron_dagger_pouch`). Also extracted `AncientGodswordSpecialAttack.kt`'s Blood +Sacrifice heal-cap math (15% of target base HP, further capped by a flat player/NPC ceiling, never +exceeding the damage actually dealt) out of the `WorldQueueList`-coupled `heal()` method into a +standalone `BloodSacrificeHeal.healAmount(...)` object, then added `BloodSacrificeHealTest.kt` +covering all three cap interactions (damage-bound, percent-bound, flat-cap-bound) plus the zero +case. Broader batch test-writing across the remaining untested special-attack files (most of which +are flat accuracy/damage multipliers with little to extract, per the earlier note under "Test +files") is still open - this pass only covered what came up naturally while doing the poison work. + +## Soulreaper axe (o) missing normal-attack combat data (resolved) + +Same pattern as the earlier "missing normal-attack audit" and Leagues-exclusive-item findings: +`obj.soulreaper_axe_orn` (the ornament kit variant, id 33335) had no `weaponCategory` or +`attack_anim_stance*`/`bas_*` params in `items.toml` at all, so it fell through to the engine's +generic unarmed-shaped fallback for normal attacks despite `SoulreaperAxeSpecialAttack.kt` already +correctly handling the ornament variant for the special. Fixed by adding a second `[[item]]` block +for `obj.soulreaper_axe_orn`, copying the base `obj.soulreaper` block's params verbatim +(`weaponCategory="Axe"`, `seq.ancient_axe_crush`/`_slash` attack anims, `seq.ancient_axe_walk`/ +`_idle` stance anims, `equipment_sound=2232`, `attackrate=5`, `defend_anim="seq.human_unarmedblock"`). +Required a full cache rebuild (`items.toml` change). Not yet live-tested by the user. + +## Webweaver bow: added Check (charges) and Uncharge, neither existed before + +User wasn't sure whether a partial "Unload" exists on this item at all ("we cant unload it i +think, well idk if u can do that gotta check wiki") and separately couldn't check remaining +charges anywhere. Settled both against a real cache dump +(`mcp__osrs-wiki-cache-utils__search_cache`, `config/obj`) rather than guessing: +`wild_cave_webweaver_charged` (27655) has `iop2=Wield, iop3=Check, iop5=Uncharge` - no partial +Unload exists, only an all-or-nothing Uncharge, matching the single pooled-charge model +`ObjChargeManager` already uses for this item (one `varobj.charges_16383` count, not a +dart/scale-style split like the blowpipe). The uncharged variant (`wild_cave_webweaver_uncharged`, +27652) only has `iop2=Wield, iop3=Dismantle` - no Check/Uncharge there, correctly. + +Added to `WebweaverBowCharging.kt`: `onOpHeld3` (inventory Check), `onOpWorn2` (worn Check - see +below), and `onOpHeld5` (Uncharge). `checkCharges` just reports the current `ObjChargeManager` +count. `uncharge` mirrors `TumekensShadowCharging.uncharge`'s exact shape: bail if already at 0 +charges, bail if inventory has no free space, `choice2` confirmation dialog, then +`charges.removeAllCharges(...)` and `invAddOrDrop(objRepo, ETHER_ITEM, removed)` to return the +revenant ether (`obj.wild_cave_shard`). + +Used `onOpWorn2` rather than `onOpWorn1` for the worn Check, on purpose, even though the real +client shows Check at `wear_op1` for this item - same lesson learned the hard way on the toxic +blowpipe earlier this session (`onOpWorn1`'s own doc says it replaces this engine's default +unequip/Remove handling, so registering it for anything else silently breaks Remove). `onOpWorn2` +is the same safe slot `TumekensShadowCharging` already uses for its own worn-Check, so this was +applied preemptively instead of being found as a live bug again. + +Full project `compileKotlin`/`test` passes. Cache rebuilt (for the Soulreaper axe items.toml +change - the Webweaver bow change is pure Kotlin and didn't need it). Server restarted clean, zero +errors in the boot log. Neither fix has been live-tested by the user yet. + +## Systematic sweep: 29 more "special works, normal attack doesn't" weapons found and fixed + +Prompted by the Soulreaper axe (o) fix above - user asked "is there no special attack we have on +unimplemented weapons anymore, cuz soulreaper (o) was like that." Answer: no, there were 29 more. +Found them precisely (not a guess-and-check sweep) by extracting every `obj.*` item alias +referenced anywhere in `content/other/special-attacks/src/main/kotlin` (i.e. every weapon that +already has a *working special attack registered*) and cross-referencing that list against every +`items.toml` block that has `weaponCategory` set but no `attack_anim_stance1` param at all - the +exact same shape of bug as Soulreaper axe (o). This is a stronger technique than the earlier +2026-08-29 sweep (see [[leagues-exclusive-items-missing-combat-data]]), which only checked base +item names and missed the "(o)"/corrupted/deadman sibling ids entirely. + +**Fixed by mirroring the real base weapon's `items.toml` block** (same technique as Soulreaper axe +(o) - merge base weapon's animation params onto the variant, but *preserve* any of the variant's +own already-set overrides like a different `attackrate`, since several of these are deliberately +rebalanced for Deadman mode): + +- **Bounty Hunter "corrupted" dragon weapons (17 items)**: dagger + (p)/(p+)/(p++), spear + + (p)/(p+)/(p++), 2h sword, battleaxe, halberd, longsword, mace, scimitar, shortsword, warhammer, + and the dragon crossbow (`obj.xbows_crossbow_dragon`). All had `weaponCategory` and often + `equipment_sound`/`attackrate` already set (correctly, sometimes with BH-specific values) but + zero animation params at all - would have fallen back to the punch/kick unarmed animation on + every normal hit despite each one's dedicated special attack already working. +- **Deadman mode weapons (8 items)**: `deadman_ags`/`deadman_blighted_ags` (mirrors `obj.ags`, + the real Armadyl godsword alias - not `armadyl_godsword`), `deadman_darkbow`/ + `deadman_blighted_dark_bow` (mirrors `obj.darkbow`), `deadman_voidwaker`/ + `deadman_blighted_voidwaker`, `deadman_nightmare_staff_volatile`/ + `deadman_blighted_volatile_staff` (mirrors `obj.nightmare_staff_volatile`). +- **`dinhs_bulwark_ornament`** and **`br_dual_macuahuitl`**: same pattern, mirrored from + `obj.dinhs_bulwark`/`obj.dual_macuahuitl`. +- **`obj.toxic_sotd_deadman`/`obj.toxic_sotd_charged_deadman`**: these had *no items.toml block at + all* (not caught by the weaponCategory-based sweep at all - found separately by checking every id + `StaffOfTheDeadSpecialAttack.kt` registers). Added brand new blocks mirroring + `obj.toxic_sotd`/`obj.toxic_sotd_charged`. +- **`obj.emberlight`**: real DT2 weapon (The Whisperer reward), genuinely has *no* unique + normal-attack animation anywhere in the cache - confirmed via both `gameval_search` and + `search_cache` kind=`config/seq`, only its special-attack animation + (`seq.human_weapon_emberlight_01_spec`) and vfx exist. Wiki's own combat-style table (Chop/Slash + Accurate, Slash Aggressive, Lunge Stab Controlled, Block Slash Defensive) matches the same + slash-heavy/one-stab-style shape as Voidwaker and Dragon scimitar exactly, so it reuses their + same generic `seq.human_sword_slash`/`seq.human_sword_stab`/`seq.human_sword_def` set - real OSRS + itself doesn't always give a new weapon bespoke swing frames, only a special. +- **`obj.bh_dragon_claws_corrupted`**: this one wasn't just a missing-animation bug - its special + attack was *never registered at all* in `DragonClawsSpecialAttack.kt` (only `obj.dragon_claws` + was). Added the missing `registerMelee("obj.bh_dragon_claws_corrupted", DragonClaws(manager))` + line plus the same animation mirror as the others. + +**Found but deliberately not fixed this pass** (lower confidence, need their own look): 3 pairs of +skilling-tool "grip state" variants referenced by `StatBoostSpecialAttacks.kt` +(`trailblazer_reloaded_axe/_empty/_no_infernal`, and the analogous harpoon/pickaxe forms, plus +`crystal_axe_2h`/`dragon_axe_2h`/`3a_axe_2h`) - the `_2h` suffix's exact semantics are unconfirmed +(possibly a cosmetic two-handed grip render used only during the woodcutting animation, not a real +combat-equipped state), so mirroring combat data onto them without checking that first risks +animating something that's never actually the equipped fighting state. Also +`arclight_inactive`/`crystal_axe_2h_inactive` (pre-activation states, correctly out of scope - same +as barrows' "broken" items) and `osb10_dragon_candle` (holiday novelty, not a real combat weapon). + +Full project `compileKotlin`/`test` passes. Cache rebuilt (all these are items.toml/new-block +changes). Server restarted clean, zero errors in the boot log. None of these 30 fixes (29 items + +the Dragon claws BH registration) have been live-tested by the user yet. + +### AGS special height (resolved) + a proper family-based re-sweep (4 more registration gaps found) + +**Armadyl godsword special height fixed** - same `height = 96` bug as Osmumten's fang, in +`ArmadylGodswordSpecialAttack.kt`'s single shared `smash()` function. Dropped to `height = 0`. +Covers all five registrations that share it (`ags`, `br_ags`, `deadman_ags`, `agsg`, +`deadman_blighted_ags`) in one fix. Pure Kotlin, no cache rebuild needed. + +**User live-tested `bh_dragon_claws_corrupted` and confirmed the special still said "not +implemented"** despite the registration fix being verifiably deployed (checked the actual compiled +bytecode in both the jar and the classes dir the server loads from, not just the source - it was +there). Turned out the user was actually wielding a *different* item, `obj.deadman_blighted_dragon_claws` +(28534, "Dragon claws (cr)" for Deadman mode) - a sibling variant I hadn't touched at all, not the +Bounty Hunter one (28039) I'd fixed. Both display similarly enough to look like the same bug report. + +**This prompted redoing the sweep properly** - the previous technique (cross-reference items +*already referenced somewhere* in special-attacks `.kt` files against items.toml animation gaps) +structurally cannot catch a weapon family where SOME siblings are registered but others were simply +forgotten entirely, since the missing ones never show up as "referenced" in the first place. New +technique: extract the exact `obj.*` id argument passed to every `registerMelee`/`registerRanged`/ +`registerMagic`/`registerInstant` call, strip known variant prefixes (`bh_`, `deadman_`, +`deadman_blighted_`, `br_`, `cert_`) and suffixes (`_corrupted`, `_ornament`, `_orn`, `_imbue`, +`_inactive`) to get each weapon's "family root", group registered ids by root, then for every root +grep `items.toml` for every id containing that root and diff against the *global* registered set +(not just that root's own subset - needed to avoid false positives from ids like +`dragon_dagger_p`/`_p+`/`_p++` each becoming their own separate root under this stripping scheme). + +**Real gaps found and fixed** (registration only - all four already had correct normal-attack +animation data in `items.toml`, so this was purely the special-attack side): +- `obj.br_dragon_claws`, `obj.dragon_claws_ornament`, `obj.deadman_blighted_dragon_claws` - the + full Dragon claws family, added to `DragonClawsSpecialAttack.kt` alongside the two already there. + `deadman_blighted_dragon_claws` additionally needed the items.toml animation mirror too (same + gap pattern as the BH corrupted one, just never caught since it isn't referenced by the special + file at all - the old sweep technique's blind spot exactly). + +**False positives correctly ruled out before touching anything** (multi-line `registerX(...)` calls +where the id string is on a line after the opening paren defeat a naive single-line grep, but the +registration is real): `obj.bh_dragon_longsword_imbue`, `obj.morrigans_javelin_bh`, +`obj.statius_warhammer_bh`, `obj.vestas_spear_bh` were all already correctly registered. + +**One genuinely interesting false positive, not a multi-line issue**: `obj.daganoth_cave_magic_shortbow` +looked like a real missing Magic shortbow variant (has full combat data, distinct RSCM alias) and +was added to `MagicShortbowSpecialAttack.kt` - which **crashed the server on boot**: +`IllegalStateException: Weapon already has a special attack mapped: 6724`. Turned out +`obj.daganoth_cave_magic_shortbow` and Seercull (registered by raw numeric id `6724`, since +Seercull itself has no RSCM alias in this revision - see the comment already in +`SeercullSpecialAttack.kt`) are literally **the same cache item under two different names** - +Seercull's real internal cache name apparently *is* `daganoth_cave_magic_shortbow` (Jagex dev +naming, presumably referencing where/how it was originally conceived). Confirmed via +`gameval_search`: both resolve to id 6724. Reverted the bad registration immediately. **Lesson**: +a distinct RSCM alias with full combat data is not proof of a distinct underlying item - two +aliases can share one numeric id, and `SpecialAttackRegistry.add` throwing on `AlreadyAdded` at +startup is a hard crash (not a soft failure), so this class of mistake is at least loud and +immediate rather than silently wrong. + +**A second real issue hit during the crash-recovery restart**: after reverting and restarting, the +new server process failed with `BindException: Address already in use` on ports 8080/43594 - the +*previous, crashed* server process didn't fully release its listening sockets on the way down. +Had to find and kill the zombie PID directly (`netstat`, not just re-running the normal kill-by-port +step, since the first restart attempt's failure happened AFTER the port had already been bound but +BEFORE "Server ready", so the standard "kill whatever's on 43594 first" step from earlier in the +same restart cycle didn't apply to it). + +**Deliberately not fixed, flagged for its own dedicated pass**: `crystal_halberd`'s huge charge-tier +family (`crystal_halberd_100` through `_1000`, `_new`, `_inactive`, plus a whole parallel +`nzone_crystal_halberd_*` set) - none of the ~20 sibling ids are registered anywhere. This is a +different, bigger problem than a forgotten `registerMelee` call: crystal halberd's charge/degrade +system was already pulled out to `disabled-tier-b/` earlier in this project's history (per git +status), meaning this was a deliberate scope cut, not an oversight, and deserves its own look at +the charge-tier damage scaling rather than a blind mirror-and-register pass. Also skipped: +`obj.dummy_dragon_knife_off`/`_p` (internal offhand-slot placeholders, not real player items) and +`obj.macro_broken_dragon_pickaxe` (anti-macro detection fixture, not a real item). + +Full project `compileKotlin`/`test` passes. Cache rebuilt (the `deadman_blighted_dragon_claws` +toml addition needed it). Server restarted clean after resolving the crash and the port conflict, +zero errors in the boot log. + +## Correction: the "trailblazer_*_no_infernal" family is NOT a cosmetic grip render + +User reported "Dragon axe (or) can't be equipped." Looked it up in the real `SERVER` cache by +*name* rather than guessing an alias (`cache_search` with `type="item"`, not `type="obj"` - that +type value returns world objects/locs in this tool, not inventory items, which is why several +earlier `type="obj"` queries this session against the wrong table came back empty) and found "Dragon +axe (or)" is real cache item 25378, whose actual RSCM alias is `obj.trailblazer_axe_no_infernal` - +and the Trailblazer Reloaded League counterpart, `obj.trailblazer_reloaded_axe_no_infernal` (30352), +is also named "Dragon axe (or)" in-game. + +This directly overturns the "lower confidence, deliberately not fixed" call from the earlier sweep +above, which speculated the whole `trailblazer_*_no_infernal`/`_empty` family might be "a cosmetic +two-handed grip render used only during the woodcutting animation, not a real combat-equipped +state." That was wrong - they're real, named, wearable weapons (`equipSlot=3` confirmed in the +built cache, no stat requirement blocking them), just following the "empty/no_infernal" naming +OSRS uses for a tool's *depleted infernal charge* state, not a rendering variant. + +**Real gap found**: all 9 Trailblazer **Reloaded** League tool variants (axe/harpoon/pickaxe x +base/empty/no_infernal) had zero animation data in `items.toml`, while their original Trailblazer +League counterparts all had it in full - the second league's parallel item set was never converted, +same "forgot the sibling league" pattern as the Deadman/BH variants found earlier. Mirrored the +original league's combat data (`attack_anim_stance*`, sounds, `defend_anim`) onto all 9, skipping +`skill_anim`/`levelrequire` (real woodcutting-tool params that likely have their own distinct +Reloaded-League sequence names - not something to blind-copy from the original league without +checking, and out of scope for an equip/combat fix). + +**Caveat turned out to be the real bug.** User confirmed they were actually testing id 25378 - +`trailblazer_axe_no_infernal`, "Dragon axe (or)" - which already had full animation data before the +toml fix above, so the animation gap genuinely wasn't why it couldn't be equipped. Real root cause, +found by checking the item's actual op layout via a live cache dump: this whole family's "Wield" +option sits at **`iop1`**, not `iop2` like almost every other weapon. This engine's *default* equip +handling is hardcoded to only fire on op2 (`HeldInteractions.opHeld2`, and `onOpHeld2`'s own doc +comment: "replaces the default wield/wear op handling") - clicking "Wield" on these items sends an +Op1 interaction, which routes to the generic op1 fallback, which does nothing since no script was +registered for it. Structurally the exact same class of bug as the `onOpWorn1`/Remove issue found +earlier this session on the toxic blowpipe (a real cache op label overriding what this engine +hardcodes for that action) - just on the *held* side instead of *worn*, and blocking Wield instead +of Remove. + +Confirmed via a live cache dump that all 18 Trailblazer/Trailblazer Reloaded tool variants (axe/ +harpoon/pickaxe x base/empty/no_infernal, both leagues) share this same `iop1=Wield` layout - not +just the one item reported. Fixed with a new `TrailblazerToolWieldFix.kt` +(`content/other/special-weapons/.../scripts/`): registers `onOpHeld1` for all 18 ids, manually +delegating to `HeldInteractions.equip(...)` - the same bypass entry point the engine's own op2 +handler calls internally, exposed specifically for this kind of override. + +This is very likely not exhaustive across the whole item database - any other real item whose +`iop1` happens to be its primary "Wield"/"Wear" action (rather than the more common `iop2`) would +hit the same bug, and there's no practical way to sweep all ~34k items for this ahead of time. +Fixed the confirmed, reported family; if another specific item turns up unequippable, check its +real op layout the same way before assuming it's the same missing-animation-data pattern as +everything else this session. + +Full project `compileKotlin`/`test` passes. No cache rebuild needed for the wield fix itself (pure +Kotlin) - cache was already rebuilt for the animation-data toml fix. Server restarted clean, script +count went 281 -> 282 confirming the new script registered. Not yet live-tested by the user. 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..56fe6db92 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.ToxicBlowpipeSpecialAttack +import org.rsmod.content.other.special.attacks.ranged.TonalzticsOfRalosSpecialAttack +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/ - see PROGRESS.md. 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..feddac7e0 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,58 @@ 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") + return true } private fun lumberUpRed(access: ProtectedAccess): Boolean { 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..2a73595c2 --- /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 = SPOTANIM_HEIGHT, + ) + + 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 = SPOTANIM_HEIGHT, + ) + + 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..44903b2a4 --- /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 = 96 + 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..5ee86f664 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonSpecialAttack.kt @@ -0,0 +1,81 @@ +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") + target.spotanim( + spot = "spotanim.abyssal_miasma_spotanim_bludgeon", + slot = constants.spotanim_slot_combat, + height = 96, + ) + + 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) + } + } +} + +/** + * 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..327260501 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerSpecialAttack.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.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 { + 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") + // 96 (blindly copied from Dragon claws, the same pattern already fixed on several + // other weapons this session - all ended up needing 0, not a smaller-but-still- + // nonzero value) sat too high. + 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..37e219274 --- /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 = 96, + ) + + 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..ef4ae876d --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArkanBladeSpecialAttack.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.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, + // 96 (blindly copied from Dragon claws) still floated too high even at 48 per + // live feedback - dropped to ground level. Visual-tuning guess, not verified + // against a real screenshot; needs your eyes to confirm. + 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..9cb304331 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ArmadylGodswordSpecialAttack.kt @@ -0,0 +1,80 @@ +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) + 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" + } +} 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..e2ee5592d --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BarrelchestAnchorSpecialAttack.kt @@ -0,0 +1,118 @@ +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") + spotanim( + spot = "spotanim.brain_anchor_special_attack_spot", + height = 96, + ) + 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 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..082d36ee3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreSpecialAttack.kt @@ -0,0 +1,146 @@ +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") + spotanim( + spot = "spotanim.olaf2_brine_sabre_special_spot", + slot = constants.spotanim_slot_combat, + height = 96, + ) + + 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 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..efbf209c1 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawsSpecialAttack.kt @@ -0,0 +1,189 @@ +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") + spotanim( + spot = "spotanim.vfx_burning_claws_spec_02", + slot = constants.spotanim_slot_combat, + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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) + } + } +} + +/** + * 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..f79cff13b --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/CrystalHalberdSpecialAttack.kt @@ -0,0 +1,100 @@ +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.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 + +/** + * 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, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val sweep = Sweep(manager, targets, pvp) + 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, + ) : 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") + spotanim(HalberdSpecialVisuals.forTarget(player.coords, primary.coords)) + + 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..5572da9ea --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DemonbaneSpecialAttacks.kt @@ -0,0 +1,119 @@ +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.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) + + val emberlight = Weaken(manager, demonDrainPercent = 15) + registerMelee("obj.emberlight", emberlight) + } + + private inner class Weaken( + private val manager: SpecialAttackManager, + private val demonDrainPercent: Int, + ) : 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") + 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 + } +} 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..bcb8928b8 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DogswordSpecialAttack.kt @@ -0,0 +1,179 @@ +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.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) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + registerMelee( + "obj.echo_godsword", + PowerOfTheGods( + manager = manager, + sacrifice = sacrifice, + bloodSacrificeDamage = LEAGUE_BLOOD_SACRIFICE_DAMAGE, + bloodSacrificeHealCap = LEAGUE_BLOOD_SACRIFICE_HEAL_CAP, + ), + ) + registerMelee( + "obj.deadman_dogsword", + PowerOfTheGods( + manager = manager, + sacrifice = sacrifice, + bloodSacrificeDamage = DEADMAN_BLOOD_SACRIFICE_DAMAGE, + bloodSacrificeHealCap = DEADMAN_BLOOD_SACRIFICE_HEAL_CAP, + ), + ) + } + + private class PowerOfTheGods( + private val manager: SpecialAttackManager, + private val sacrifice: AncientGodswordBloodSacrifice, + 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 = 96, + ) + 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, + ) + (target as? Player)?.let { CombatEffects.freeze(it, 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..4f0bc1eb5 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/Dragon2hSwordSpecialAttack.kt @@ -0,0 +1,92 @@ +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") + 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 + } + } +} 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..473ee4a96 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsSpecialAttack.kt @@ -0,0 +1,212 @@ +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 = 96, + ) + + val maxHit = + manager.calculateMeleeMaxHit( + source = this, + target = target, + attackType = attack.type, + attackStyle = attack.style, + multiplier = 1.0, + ) + + val hits = rollHits(target, attack, maxHit) + + var totalDamage = 0 + for (damage in hits) { + totalDamage += damage + manager.queueMeleeHit(this, target, damage) + } + manager.giveCombatXp(this, target, attack, totalDamage) + manager.continueCombat(this, target) + } + + /** + * 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) }, + ) + } +} + +/** + * 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..ed8fe3178 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonDaggerSpecialAttack.kt @@ -0,0 +1,101 @@ +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 { + puncture(target, attack, secondHitDelay = 2) + return true + } + + override suspend fun ProtectedAccess.attack( + target: Player, + attack: CombatAttack.Melee, + ): Boolean { + puncture(target, attack, secondHitDelay = 1) + return true + } + + private fun ProtectedAccess.puncture( + target: PathingEntity, + attack: CombatAttack.Melee, + secondHitDelay: Int, + ) { + anim("seq.puncture") + 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) + } + } +} 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..4c8325ed9 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHalberdSpecialAttack.kt @@ -0,0 +1,100 @@ +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.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 + +/** + * 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, +) : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val sweep = Sweep(manager, targets, pvp) + 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, + ) : 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") + spotanim(HalberdSpecialVisuals.forTarget(player.coords, primary.coords)) + + 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/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..751f54954 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonHastaSpecialAttack.kt @@ -0,0 +1,239 @@ +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) + + // This was missing entirely - without a player anim, the special's spotanim fired but + // the player's swing didn't sync with it at all, instead just showing whatever the + // normal attack happened to play. `seq.specialattack_unleash` is a generic, named + // "Unleash" animation already present in this cache. + anim("seq.specialattack_unleash") + spotanim( + spot = "spotanim.dragon_hasta_spec_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + 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 own "Stun (status)" page, + * Shove's stun has no immunity window and can be chained indefinitely - unlike torka's + * original file, which invented a one-cycle post-stun immunity that doesn't exist in the real + * game. 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 + } + + // The cache has a purpose-built attacker/target sequence and matching graphics. + anim("seq.shove") + 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 = 96, + ) + + 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 + } + // A plain teleport rather than a smoothed client-side slide (`PathingEntityCommon`'s + // `exactMove` primitive) - real Shove knockback is instant, but this engine has no + // existing usage of `exactMove` to confirm its timing/direction parameters against, so + // this trades away that slide animation for a simpler, unambiguously-correct move. + PathingEntityCommon.teleport(target, collision, destination) + } + } + + private companion object { + const val MINIMUM_ENERGY: Int = 50 + const val STUN_CYCLES: Int = 5 + 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..58577c21d 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,10 @@ class DragonLongswordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.cleave(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.cleave") + if (nextAttackDelay != null) { + manager.setNextAttackDelay(this, nextAttackDelay) + } + spotanim( spot = "spotanim.sp_attack_cleave_spotanim", slot = constants.spotanim_slot_combat, @@ -47,7 +69,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..39c8adaa7 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonMaceSpecialAttack.kt @@ -0,0 +1,63 @@ +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") + 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) + } + } +} 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..8105e589f --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonScimitarSpecialAttack.kt @@ -0,0 +1,70 @@ +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") + 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) + } + } +} 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..88f8d95a5 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonSwordSpecialAttack.kt @@ -0,0 +1,71 @@ +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") + 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) + } + } +} 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..5dd1dcc82 --- /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 = 96, + ) + + 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..42e3c01d1 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ElderMaulSpecialAttack.kt @@ -0,0 +1,92 @@ +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") + spotanim( + spot = "spotanim.spotanim_elder_maul_special", + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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 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..46170d019 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/HalberdSpecialVisuals.kt @@ -0,0 +1,22 @@ +package org.rsmod.content.other.special.attacks.melee + +import kotlin.math.abs +import org.rsmod.map.CoordGrid + +/** Selects the red sweep graphic that faces from the wielder toward the primary target. */ +internal object HalberdSpecialVisuals { + fun forTarget(source: CoordGrid, target: CoordGrid): String { + val deltaX = target.x - source.x + val deltaZ = target.z - source.z + return if (abs(deltaX) > abs(deltaZ)) { + if (deltaX >= 0) EAST else WEST + } else { + if (deltaZ >= 0) NORTH else SOUTH + } + } + + const val WEST = "spotanim.dragon_halberd_special_west_red" + const val SOUTH = "spotanim.dragon_halberd_special_south_red" + const val NORTH = "spotanim.dragon_halberd_special_north_red" + const val EAST = "spotanim.dragon_halberd_special_east_red" +} 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..526600ac5 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacks.kt @@ -0,0 +1,410 @@ +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, + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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, + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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, + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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) { + 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, + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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..83192c624 --- /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 = 96, + ) + 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..7c7af1d9c --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangSpecialAttack.kt @@ -0,0 +1,94 @@ +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") + // Same blind height=96-copied-from-Dragon-claws pattern fixed on several other + // specials this session - reported too high live, dropped to ground level. + 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) + } + } +} + +/** + * 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..cb76382df --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/RuneClawsSpecialAttack.kt @@ -0,0 +1,65 @@ +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") + spotanim( + spot = "spotanim.sp_attack_impale_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + + // 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) + } + } +} 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..d3bd62848 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominBlessedSwordSpecialAttack.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.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 + +/** + * 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 : SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val lightning = BlessedLightning(manager) + registerMelee("obj.blessed_saradomin_sword", lightning) + registerMelee("obj.blessed_saradomin_sword_degraded", lightning) + } + + private class BlessedLightning(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.blessed_saradomin_sword_special_player") + target.spotanim( + spot = "spotanim.saradomin_lightning", + height = 96, + slot = constants.spotanim_slot_combat, + ) + + 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..dc80ec7d9 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominSwordSpecialAttack.kt @@ -0,0 +1,100 @@ +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") + target.spotanim( + spot = "spotanim.saradomin_lightning", + height = 96, + 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 + } + } +} + +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..928ce2ed1 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeSpecialAttack.kt @@ -0,0 +1,154 @@ +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, + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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..b62a4d709 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/StatiusWarhammerSpecialAttack.kt @@ -0,0 +1,111 @@ +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") + spotanim( + spot = "spotanim.statius_hammer_sa_spotanim", + slot = constants.spotanim_slot_combat, + height = 96, + ) + 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) + } + } + } +} 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..6621b69cf --- /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.specials.SpecialAttackManager +import org.rsmod.api.specials.SpecialAttackMap +import org.rsmod.api.specials.SpecialAttackRepository +import org.rsmod.api.specials.combat.MeleeSpecialAttack +import org.rsmod.api.player.righthand +import org.rsmod.api.specials.combat.MagicSpecialAttack +import org.rsmod.game.inv.InvObj +import org.rsmod.game.entity.Npc +import org.rsmod.game.entity.Player + +/** + * 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 = 96, + ) + 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..b0c4674a2 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaSpearSpecialAttack.kt @@ -0,0 +1,126 @@ +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)) + player.spotanim( + spot = RSCM.getReverseMapping(RSCMType.SPOTANIM, VESTA_SPEAR_WALL_SPOTANIM), + height = 96, + 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 + } +} 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..246f0d9e7 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VoidwakerSpecialAttack.kt @@ -0,0 +1,89 @@ +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") + spotanim( + spot = "spotanim.fx_voidwaker02_special", + slot = constants.spotanim_slot_combat, + height = 96, + ) + target.spotanim( + spot = "spotanim.fx_voidwaker_impact", + height = 96, + 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 + } + } +} 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..cc618a172 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialAttack.kt @@ -0,0 +1,402 @@ +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.CombatEffects +import org.rsmod.api.combat.commons.CombatAttack +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 = 96) + } + 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..af4bc5f61 --- /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 = 96, slot = constants.spotanim_slot_combat) + + val launchSpot = + quiverType.paramOrNull(params.proj_launch)?.let { + RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) + } + spotanim(launchSpot, height = 96, 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..d5cd8bf14 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 @@ -30,6 +30,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( @@ -93,7 +96,7 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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) @@ -115,7 +118,12 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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 @@ -151,7 +159,7 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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) @@ -172,7 +180,13 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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 @@ -226,8 +240,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 +267,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..bc8b8f481 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialAttack.kt @@ -0,0 +1,179 @@ +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.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.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 + } + if (!playRangedWeaponFx(weaponType)) { + manager.stopCombat(this) + mes("The crossbow fails to fire.") + return false + } + + val launchSpotanim = + quiverType.paramOrNull(params.proj_launch)?.let { + RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) + } + spotanim( + launchSpotanim, + height = 96, + slot = constants.spotanim_slot_combat, + ) + + 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, + ) + val projectileSpotanim = + RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id) + val projectileTypeName = + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id) + val projectiles = + affected.associateWith { target -> + manager.spawnProjectile( + this, + target, + projectileSpotanim, + 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]) + 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 + } +} + +/** 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..2f010b1e8 --- /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 = 96, + ) + + 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 = 96, + ) + 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..e8de443ec --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowSpecialAttack.kt @@ -0,0 +1,145 @@ +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 + } + + val launchSpot = + quiverType.paramOrNull(params.proj_launch)?.let { + RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) + } + spotanim(launchSpot, height = 96, slot = constants.spotanim_slot_combat) + + val projectile = + manager.spawnProjectile( + this, + target, + RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id), + 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 + } + } +} + +/** + * 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..7e227843d --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicShortbowSpecialAttack.kt @@ -0,0 +1,176 @@ +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.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 + +/** + * 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. + */ +class MagicShortbowSpecialAttack @Inject constructor(private val ammunition: RangedAmmoManager) : + SpecialAttackMap { + override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { + val snapshot = Snapshot(manager, ammunition) + 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, + ) : 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("seq.snapshot") + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + target.spotanim( + "spotanim.sp_attack_snapshot_spotanim", + height = 96, + slot = constants.spotanim_slot_combat, + ) + // Matches PvNCombat's own standard ranged-attack handling: the launch spotanim plays + // on the shooter (a muzzle-flash-style effect), not the target, and is genuinely + // absent for some ammo (no proj_launch param) - null is a real, expected case here. + val launchSpotanim = + quiverType.paramOrNull(params.proj_launch_double) + ?: quiverType.paramOrNull(params.proj_launch) + val launchSpotanimName = + launchSpotanim?.let { RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) } + spotanim(launchSpotanimName, height = 96, slot = constants.spotanim_slot_combat) + + val travelSpot = RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id) + val projanim = RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id) + val firstProjectile = manager.spawnProjectile(this, target, travelSpot, projanim) + val secondProjectile = manager.spawnProjectile(this, target, travelSpot, projanim) + 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, + ) + + ammunition.useQuiverAmmo( + player = player, + quiverType = quiverType, + dropCoord = target.coords, + dropDelay = secondProjectile.serverCycles, + ) + manager.queueRangedDamage( + source = this, + target = target, + ammo = quiverType, + damage = secondDamage, + hitDelay = secondProjectile.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 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..9450469d5 --- /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 = 96, + 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..ba70a53c3 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeSpecialAttack.kt @@ -0,0 +1,217 @@ +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) + spotanim( + spot = MORRIGANS_THROWING_AXE_LAUNCH_SPOTANIM, + // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live + // feedback - dropped to ground level. Visual-tuning guess, not verified against a + // real screenshot; needs your eyes to confirm. + 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" + } +} + +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..e0273284e --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RosewoodBlowpipeSpecialAttack.kt @@ -0,0 +1,146 @@ +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.constants +import org.rsmod.api.config.refs.params +import org.rsmod.api.player.righthand +import org.rsmod.api.player.protect.ProtectedAccess +import org.rsmod.api.player.ranged.BlowpipeAmmo +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..ca96683ec --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/RuneThrownaxeSpecialAttack.kt @@ -0,0 +1,356 @@ +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) + 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" + 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..807e2e0f9 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/SeercullSpecialAttack.kt @@ -0,0 +1,154 @@ +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 + } + + val launchSpotanim = + arrowType.paramOrNull(params.proj_launch)?.let { + RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) + } + spotanim(launchSpotanim, height = 96, slot = constants.spotanim_slot_combat) + + val projectile = + manager.spawnProjectile( + this, + target, + RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id), + RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), + ) + + // 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 + } +} + +/** 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..d37a7ba2f --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialAttack.kt @@ -0,0 +1,204 @@ +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) + 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" + } +} + +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..5402b6274 --- /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.constants +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..444e7f700 --- /dev/null +++ b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowSpecialAttack.kt @@ -0,0 +1,244 @@ +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 = 96, + 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 = 96, + ) + 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 + } + + // This used to hand-roll the bit manipulation directly (reading/writing `player.righthand` + // vars via `getBits`/`withBits`), resolving the varobj via `RSCMType.VAROBJ` - correct on + // its own, but `ObjChargeManager` (the shared, intended primitive for this exact purpose) + // had its own separate, real bug: three of its four functions resolved the same kind of + // "varobj.*" string via `RSCMType.VARCON` instead, which is a completely unrelated + // namespace (content-script tracking vars, not item charges) - `asRSCM` enforces an exact + // prefix match, so that always threw `IllegalArgumentException`. Fixed at the source in + // `ObjChargeManager.kt` rather than worked around here, since it affected every caller + // (Tumeken's shadow included) the moment they actually exercised these paths. + 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..74681d728 --- /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 = 96) + 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 = 96) + 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-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt new file mode 100644 index 000000000..0a685658d --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt @@ -0,0 +1,23 @@ +package org.rsmod.content.other.special.attacks.boost + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DragonBattleaxeDrainTest { + @Test + fun `drains 10 percent of the current level, rounded down`() { + assertEquals(9, DragonBattleaxeDrain.drainAmount(99)) + assertEquals(0, DragonBattleaxeDrain.drainAmount(9)) + } + + @Test + fun `strength boost is 10 plus a quarter of the total drained`() { + // At 99 in all four drained stats: 4 * 9 = 36 total drained, boost = 10 + 36/4 = 19. + assertEquals(19, DragonBattleaxeDrain.strengthBoost(totalDrained = 36)) + } + + @Test + fun `strength boost floors to 10 when nothing was drained`() { + assertEquals(10, DragonBattleaxeDrain.strengthBoost(totalDrained = 0)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt new file mode 100644 index 000000000..f7de5877a --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt @@ -0,0 +1,30 @@ +package org.rsmod.content.other.special.attacks.magic + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NightmareStaffSpecialDamageTest { + @Test + fun volatileSpecialUsesVisibleMagicScalingAndLevel98Cap() { + assertEquals(49, NightmareStaffSpecialDamage.volatileBaseMaxHit(82)) + assertEquals(53, NightmareStaffSpecialDamage.volatileBaseMaxHit(89)) + assertEquals(57, NightmareStaffSpecialDamage.volatileBaseMaxHit(96)) + assertEquals(58, NightmareStaffSpecialDamage.volatileBaseMaxHit(98)) + assertEquals(58, NightmareStaffSpecialDamage.volatileBaseMaxHit(120)) + } + + @Test + fun eldritchSpecialUsesVisibleMagicScalingAndLevel97Cap() { + assertEquals(37, NightmareStaffSpecialDamage.eldritchBaseMaxHit(82)) + assertEquals(41, NightmareStaffSpecialDamage.eldritchBaseMaxHit(90)) + assertEquals(43, NightmareStaffSpecialDamage.eldritchBaseMaxHit(96)) + assertEquals(44, NightmareStaffSpecialDamage.eldritchBaseMaxHit(97)) + assertEquals(44, NightmareStaffSpecialDamage.eldritchBaseMaxHit(120)) + } + + @Test + fun baseHitRemainsDefinedForInvalidlyLowVisibleMagic() { + assertEquals(1, NightmareStaffSpecialDamage.volatileBaseMaxHit(0)) + assertEquals(1, NightmareStaffSpecialDamage.eldritchBaseMaxHit(-1)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt new file mode 100644 index 000000000..bd1a38cea --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt @@ -0,0 +1,20 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AbyssalBludgeonDamageTest { + @Test + fun `missing prayer is base minus current, never negative`() { + assertEquals(43, AbyssalBludgeonDamage.missingPrayer(basePrayer = 99, currentPrayer = 56)) + assertEquals(0, AbyssalBludgeonDamage.missingPrayer(basePrayer = 99, currentPrayer = 99)) + // A prayer boost (current > base) still floors at zero missing, not a negative value. + assertEquals(0, AbyssalBludgeonDamage.missingPrayer(basePrayer = 99, currentPrayer = 105)) + } + + @Test + fun `damage multiplier is 0point5 percent per missing prayer point`() { + assertEquals(1.0, AbyssalBludgeonDamage.damageMultiplier(missingPrayer = 0)) + assertEquals(1.495, AbyssalBludgeonDamage.damageMultiplier(missingPrayer = 99), 1e-9) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt new file mode 100644 index 000000000..1320c68bb --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt @@ -0,0 +1,16 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class AbyssalDaggerTimingTest { + @Test + fun `second hit lands 1 tick later against a player`() { + assertEquals(1, AbyssalDaggerTiming.secondHitDelay(targetIsPlayer = true)) + } + + @Test + fun `second hit lands 2 ticks later against an npc`() { + assertEquals(2, AbyssalDaggerTiming.secondHitDelay(targetIsPlayer = false)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt new file mode 100644 index 000000000..9f4f07d10 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt @@ -0,0 +1,33 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BloodSacrificeHealTest { + @Test + fun `heals the full damage dealt when under both caps`() { + assertEquals(15, BloodSacrificeHeal.healAmount(damage = 15, targetBaseHitpoints = 200, healCap = 15)) + } + + @Test + fun `caps at fifteen percent of the target's base hitpoints`() { + // 15% of 80 base HP = 12, below the flat cap, so the percent cap is the binding one. + assertEquals(12, BloodSacrificeHeal.healAmount(damage = 25, targetBaseHitpoints = 80, healCap = 25)) + } + + @Test + fun `caps at the flat player heal cap against a high-hp target`() { + // 15% of 990 base HP would be 148, far above the real 15-hp player cap. + assertEquals(15, BloodSacrificeHeal.healAmount(damage = 25, targetBaseHitpoints = 990, healCap = 15)) + } + + @Test + fun `never heals more than the damage actually dealt`() { + assertEquals(3, BloodSacrificeHeal.healAmount(damage = 3, targetBaseHitpoints = 990, healCap = 25)) + } + + @Test + fun `zero damage heals nothing`() { + assertEquals(0, BloodSacrificeHeal.healAmount(damage = 0, targetBaseHitpoints = 200, healCap = 15)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt new file mode 100644 index 000000000..e340aa5fe --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt @@ -0,0 +1,22 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BlueMoonSpearDamageTest { + @Test + fun usesActiveBindDurationForBothMultipliers() { + val multipliers = BlueMoonSpearDamage.multipliers(bindingCycles = 5) + + assertEquals(1.075, multipliers.accuracy, 0.0001) + assertEquals(1.075, multipliers.maxHit, 0.0001) + } + + @Test + fun capsOnlyTheDamageMultiplier() { + val multipliers = BlueMoonSpearDamage.multipliers(bindingCycles = 100) + + assertEquals(2.5, multipliers.accuracy, 0.0001) + assertEquals(2.125, multipliers.maxHit, 0.0001) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt new file mode 100644 index 000000000..f7367ad46 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt @@ -0,0 +1,41 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BoneDaggerBackstabTest { + @Test + fun `guarantees accuracy when another player was the last positive damager`() { + val source = 1L + + assertTrue( + BoneDaggerBackstab.isUnsuspecting( + lastDamagingPlayerUuid = 2L, + sourceUuid = source, + ), + ) + } + + @Test + fun `uses normal accuracy after this player dealt the last positive damage`() { + val source = 1L + + assertFalse( + BoneDaggerBackstab.isUnsuspecting( + lastDamagingPlayerUuid = source, + sourceUuid = source, + ), + ) + } + + @Test + fun `allows the guarantee while a player uuid is unavailable`() { + assertTrue( + BoneDaggerBackstab.isUnsuspecting( + lastDamagingPlayerUuid = 2L, + sourceUuid = null, + ), + ) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt new file mode 100644 index 000000000..4bdffd74c --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt @@ -0,0 +1,32 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.rsmod.map.CoordGrid + +class BrineSabreLiquifyTest { + @Test + fun `adds one quarter of final damage up to the live stat cap`() { + assertEquals(12, BrineSabreLiquify.levelsToAdd(base = 99, current = 99, damage = 48)) + assertEquals(6, BrineSabreLiquify.levelsToAdd(base = 99, current = 105, damage = 48)) + assertEquals(0, BrineSabreLiquify.levelsToAdd(base = 99, current = 111, damage = 48)) + } + + @Test + fun `truncates fractional damage boosts and never boosts a zero hit`() { + assertEquals(0, BrineSabreLiquify.levelsToAdd(base = 70, current = 70, damage = 3)) + assertEquals(1, BrineSabreLiquify.levelsToAdd(base = 70, current = 70, damage = 4)) + assertEquals(0, BrineSabreLiquify.levelsToAdd(base = 70, current = 70, damage = 0)) + } + + @Test + fun `accepts only mapped underwater regions`() { + assertTrue(BrineSabreUnderwaterAreas.contains(CoordGrid(1, 46, 148, 20, 20))) + assertTrue(BrineSabreUnderwaterAreas.contains(CoordGrid(1, 59, 144, 20, 20))) + assertTrue(BrineSabreUnderwaterAreas.contains(CoordGrid(0, 58, 160, 20, 20))) + assertFalse(BrineSabreUnderwaterAreas.contains(CoordGrid(0, 46, 148, 20, 20))) + assertFalse(BrineSabreUnderwaterAreas.contains(CoordGrid(0, 50, 50, 20, 20))) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt new file mode 100644 index 000000000..dd3a663ee --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt @@ -0,0 +1,30 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BrutalSwingDamageTest { + @Test + fun `uses the exact success tier ranges`() { + assertEquals(70..109, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 1)) + assertEquals(90..129, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 2)) + assertEquals(110..149, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 3)) + assertEquals(130..169, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 4)) + } + + @Test + fun `floors percentage calculations before applying the exclusive upper bound`() { + assertEquals(51..79, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 1)) + assertEquals(65..93, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 2)) + assertEquals(80..108, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 3)) + assertEquals(94..123, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 4)) + } + + @Test + fun `keeps low maximum ranges valid`() { + assertEquals(0..0, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 1)) + assertEquals(0..0, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 2)) + assertEquals(1..1, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 3)) + assertEquals(1..1, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 4)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt new file mode 100644 index 000000000..4fdd23671 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt @@ -0,0 +1,83 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BurningClawDamageTest { + @Test + fun firstSuccessTierUsesCorrectRangeAndSplit() { + val result = + BurningClawDamage.roll( + maxHit = 39, + rollAccuracy = { true }, + rollInclusive = { range -> + assertEquals(29..68, range) + 29 + }, + rollExclusive = { error("A successful roll must not use the miss fallback.") }, + ) + + assertArrayEquals(intArrayOf(7, 7, 14), result.hits) + assertEquals(15, result.burnChancePercent) + } + + @Test + fun secondSuccessTierUsesCorrectRangeAndSplit() { + val accuracy = listOf(false, true).iterator() + val result = + BurningClawDamage.roll( + maxHit = 39, + rollAccuracy = { accuracy.next() }, + rollInclusive = { range -> + assertEquals(19..58, range) + 16 + }, + rollExclusive = { error("A successful roll must not use the miss fallback.") }, + ) + + assertArrayEquals(intArrayOf(7, 7, 2), result.hits) + assertEquals(30, result.burnChancePercent) + } + + @Test + fun thirdSuccessTierUsesCorrectRangeAndSplit() { + val accuracy = listOf(false, false, true).iterator() + val result = + BurningClawDamage.roll( + maxHit = 39, + rollAccuracy = { accuracy.next() }, + rollInclusive = { range -> + assertEquals(9..48, range) + 9 + }, + rollExclusive = { error("A successful roll must not use the miss fallback.") }, + ) + + assertArrayEquals(intArrayOf(1, 1, 7), result.hits) + assertEquals(45, result.burnChancePercent) + } + + @Test + fun failedAccuracyUsesExactFallbackAndCannotBurn() { + fun misses(roll: Int): BurningClawDamage.Result { + val accuracy = listOf(false, false, false).iterator() + return BurningClawDamage.roll( + maxHit = 39, + rollAccuracy = { accuracy.next() }, + rollInclusive = { error("A failed roll must not use a damage range.") }, + rollExclusive = { + assertEquals(5, it) + roll + }, + ) + } + + assertArrayEquals(intArrayOf(0, 0, 0), misses(0).hits) + assertArrayEquals(intArrayOf(0, 0, 1), misses(1).hits) + assertArrayEquals(intArrayOf(0, 0, 1), misses(2).hits) + assertArrayEquals(intArrayOf(0, 0, 2), misses(3).hits) + assertArrayEquals(intArrayOf(0, 0, 2), misses(4).hits) + assertEquals(0, misses(0).burnChancePercent) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt new file mode 100644 index 000000000..3b7deea90 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt @@ -0,0 +1,252 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DragonClawsDamageTest { + /** Wiki worked example: first hit connects at 35, cascading 35-17-8-9. */ + @Test + fun `first hit connects and cascades down`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(true), + rollRange = { 35 }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertArrayEquals(intArrayOf(35, 17, 8, 9), hits) + } + + /** Wiki worked example: first hit misses, second connects at 30, cascading 0-30-15-16. */ + @Test + fun `second hit connects and cascades down`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, true), + rollRange = { 30 }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertArrayEquals(intArrayOf(0, 30, 15, 16), hits) + } + + /** Wiki worked example: third hit connects at 22, cascading 0-0-22-23. */ + @Test + fun `third hit connects and cascades down`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, true), + rollRange = { 22 }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertArrayEquals(intArrayOf(0, 0, 22, 23), hits) + } + + /** Wiki worked example: fourth hit connects at 46 with no further cascade. */ + @Test + fun `fourth hit connects with no cascade after it`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, false, true), + rollRange = { 46 }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertArrayEquals(intArrayOf(0, 0, 0, 46), hits) + } + + @Test + fun `all four miss with no sympathy damage`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, false, false), + rollRange = { fail() }, + rollSympathyTriggers = { false }, + rollSympathyPattern = { fail() }, + ) + assertArrayEquals(intArrayOf(0, 0, 0, 0), hits) + } + + @Test + fun `all four miss with sympathy damage on hits 1 and 2`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, false, false), + rollRange = { fail() }, + rollSympathyTriggers = { true }, + rollSympathyPattern = { 0 }, + ) + assertArrayEquals(intArrayOf(1, 1, 0, 0), hits) + } + + @Test + fun `all four miss with sympathy damage on hits 3 and 4`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, false, false), + rollRange = { fail() }, + rollSympathyTriggers = { true }, + rollSympathyPattern = { 1 }, + ) + assertArrayEquals(intArrayOf(0, 0, 1, 1), hits) + } + + @Test + fun `all four miss with sympathy damage on hits 1 and 3`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, false, false), + rollRange = { fail() }, + rollSympathyTriggers = { true }, + rollSympathyPattern = { 2 }, + ) + assertArrayEquals(intArrayOf(1, 0, 1, 0), hits) + } + + @Test + fun `all four miss with sympathy damage on hits 2 and 4`() { + val hits = + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, false, false), + rollRange = { fail() }, + rollSympathyTriggers = { true }, + rollSympathyPattern = { 3 }, + ) + assertArrayEquals(intArrayOf(0, 1, 0, 1), hits) + } + + @Test + fun `first hit range is half max to max minus one`() { + var capturedRange: IntRange? = null + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(true), + rollRange = { range -> + capturedRange = range + range.first + }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertEquals(23..45, capturedRange) + } + + @Test + fun `second hit range is three eighths to seven eighths of max`() { + var capturedRange: IntRange? = null + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, true), + rollRange = { range -> + capturedRange = range + range.first + }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertEquals(17..40, capturedRange) + } + + @Test + fun `third hit range is a quarter to three quarters of max`() { + var capturedRange: IntRange? = null + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, true), + rollRange = { range -> + capturedRange = range + range.first + }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertEquals(11..34, capturedRange) + } + + @Test + fun `fourth hit range is a quarter to five quarters of max`() { + var capturedRange: IntRange? = null + DragonClawsDamage.rollHits( + maxHit = 46, + rollAccuracy = accuracySequence(false, false, false, true), + rollRange = { range -> + capturedRange = range + range.first + }, + rollSympathyTriggers = { fail() }, + rollSympathyPattern = { fail() }, + ) + assertEquals(11..57, capturedRange) + } + + @Test + fun `resolveRange forces the top of the range when maxhit is active`() { + val result = + DragonClawsDamage.resolveRange( + min = 5, + max = 20, + isMaxHit = true, + rollInclusive = { _, _ -> fail() }, + rollUpTo = { fail() }, + ) + assertEquals(20, result) + } + + @Test + fun `resolveRange rolls inclusive between min and max when min is positive`() { + val result = + DragonClawsDamage.resolveRange( + min = 5, + max = 20, + isMaxHit = false, + rollInclusive = { lo, hi -> lo + hi }, + rollUpTo = { fail() }, + ) + assertEquals(25, result) + } + + @Test + fun `resolveRange rolls up to max plus one when min is zero or below`() { + val result = + DragonClawsDamage.resolveRange( + min = -3, + max = 20, + isMaxHit = false, + rollInclusive = { _, _ -> fail() }, + rollUpTo = { exclusiveBound -> exclusiveBound }, + ) + assertEquals(21, result) + } + + @Test + fun `resolveRange clamps a negative max down to zero`() { + val result = + DragonClawsDamage.resolveRange( + min = -3, + max = -10, + isMaxHit = true, + rollInclusive = { _, _ -> fail() }, + rollUpTo = { fail() }, + ) + assertEquals(0, result) + } + + /** Returns a lambda that yields the given booleans in order, one per call. */ + private fun accuracySequence(vararg results: Boolean): () -> Boolean { + var index = 0 + return { results[index++] } + } + + private fun fail(): Nothing = throw AssertionError("unexpected call") +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt new file mode 100644 index 000000000..287432cfb --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt @@ -0,0 +1,80 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DualMacuahuitlDamageTest { + @Test + fun `raises total minimum and maximum then splits the two hits`() { + val hits = + DualMacuahuitlDamage.roll( + normalMax = 20, + rollAccuracy = { true }, + rollDamage = { range -> range.last }, + ) + + assertArrayEquals(intArrayOf(12, 13), hits) + } + + @Test + fun `rolls the two special hits independently`() { + var calls = 0 + val hits = + DualMacuahuitlDamage.roll( + normalMax = 20, + rollAccuracy = { + calls++ + calls == 1 + }, + rollDamage = { range -> range.first }, + ) + + assertArrayEquals(intArrayOf(2, 0), hits) + } + + /** + * Regression test for a real `::maxhit` bug: the special's custom damage roll never checked + * `player.adminMaxHit`, so the cheat silently did nothing for this weapon while working + * correctly on the regular attack (same root cause as the original Dragon claws bug). + */ + @Test + fun `resolveDamage forces the top of the range when maxhit cheat is active`() { + val damage = + DualMacuahuitlDamage.resolveDamage( + range = 4..21, + isMaxHit = true, + rollRandom = { fail() }, + ) + assertEquals(21, damage) + } + + @Test + fun `resolveDamage rolls randomly when maxhit cheat is inactive`() { + var capturedRange: IntRange? = null + val damage = + DualMacuahuitlDamage.resolveDamage( + range = 4..21, + isMaxHit = false, + rollRandom = { range -> + capturedRange = range + range.first + }, + ) + assertEquals(4..21, capturedRange) + assertEquals(4, damage) + } + + @Test + fun `resolveDamage is zero when the range's top is zero or below, regardless of maxhit`() { + val damage = + DualMacuahuitlDamage.resolveDamage( + range = -3..0, + isMaxHit = true, + rollRandom = { fail() }, + ) + assertEquals(0, damage) + } + + private fun fail(): Nothing = throw AssertionError("unexpected call") +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt new file mode 100644 index 000000000..fe58094a5 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt @@ -0,0 +1,29 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.rsmod.api.combat.commons.CombatStance +import org.rsmod.api.combat.commons.styles.MeleeAttackStyle +import org.junit.jupiter.api.Test + +class GraniteMaulStyleTest { + @Test + fun `stance 1 maps to accurate`() { + assertEquals(MeleeAttackStyle.Accurate, GraniteMaulStyle.resolve(CombatStance.Stance1)) + } + + @Test + fun `stance 2 maps to aggressive`() { + assertEquals(MeleeAttackStyle.Aggressive, GraniteMaulStyle.resolve(CombatStance.Stance2)) + } + + @Test + fun `stances 3 and 4 map to defensive`() { + assertEquals(MeleeAttackStyle.Defensive, GraniteMaulStyle.resolve(CombatStance.Stance3)) + assertEquals(MeleeAttackStyle.Defensive, GraniteMaulStyle.resolve(CombatStance.Stance4)) + } + + @Test + fun `an unrecognized stance falls back to defensive`() { + assertEquals(MeleeAttackStyle.Defensive, GraniteMaulStyle.resolve(null)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt new file mode 100644 index 000000000..4c958c5ee --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt @@ -0,0 +1,41 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ImpactMeleeSpecialAttacksTest { + @Test + fun `bandos drain spends down stats in order, stopping once the amount is spent`() { + // Wiki's own worked example: 20 Defence, 50 Attack, 20 Prayer, an 80 hit drains + // 20 Defence, 20 Prayer (skipping Strength=0), then 40 Attack. + val order = listOf(20, 0, 20, 50, 0, 0) // Defence, Strength, Prayer, Attack, Magic, Ranged + assertEquals(listOf(20, 0, 20, 40, 0, 0), BandosStatDrain.distribute(80, order)) + } + + @Test + fun `bandos drain stops entirely once every stat is exhausted`() { + val order = listOf(5, 5) + assertEquals(listOf(5, 5), BandosStatDrain.distribute(50, order)) + } + + @Test + fun `bandos drain of zero touches nothing`() { + assertEquals(listOf(0, 0), BandosStatDrain.distribute(0, listOf(10, 10))) + } + + @Test + fun `dragon warhammer reduces player defence by 30 percent, rounded down`() { + // Wiki: floor(0.30 * 75) = 22. + assertEquals(22, DragonWarhammerDefenceReduction.playerDrain(75)) + assertEquals(0, DragonWarhammerDefenceReduction.playerDrain(0)) + } + + @Test + fun `dragon warhammer leaves npc defence at 70 percent, rounded down, and stacks multiplicatively`() { + // Wiki: 75 -> 53 -> 38 across two successful hits (not 75 -> 52 -> 34, which would be + // subtracting a fixed 30% of the *original* value each time). + val first = DragonWarhammerDefenceReduction.npcRemaining(75) + assertEquals(53, first) + assertEquals(38, DragonWarhammerDefenceReduction.npcRemaining(first)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt new file mode 100644 index 000000000..106913272 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt @@ -0,0 +1,23 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class OsmumtenFangDamageTest { + /** Wiki's own worked example: a true max hit of 60 rolls between 9 and 60. */ + @Test + fun `minimum hit matches the wiki's worked example`() { + assertEquals(9, OsmumtenFangDamage.minHit(maxHit = 60)) + } + + @Test + fun `minimum hit is 15 percent of the true max hit`() { + assertEquals(15, OsmumtenFangDamage.minHit(maxHit = 100)) + } + + @Test + fun `minimum hit never rounds down to zero`() { + assertEquals(1, OsmumtenFangDamage.minHit(maxHit = 1)) + assertEquals(1, OsmumtenFangDamage.minHit(maxHit = 6)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt new file mode 100644 index 000000000..54c2616f2 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt @@ -0,0 +1,79 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SoulreaperAxeDamageTest { + @Test + fun `accuracy multiplier is 12 percent per stack`() { + assertEquals(1.0, SoulreaperAxeDamage.accuracyMultiplier(0)) + assertEquals(1.6, SoulreaperAxeDamage.accuracyMultiplier(5), 1e-9) + } + + @Test + fun `damage multiplier is 6 percent per stack`() { + assertEquals(1.0, SoulreaperAxeDamage.damageMultiplier(0)) + assertEquals(1.3, SoulreaperAxeDamage.damageMultiplier(5), 1e-9) + } + + @Test + fun `minimum damage at 5 stacks is 30 percent of the boosted max hit`() { + // maxHit here is already the boosted value (calculateMeleeMaxHit is called with + // damageMultiplier applied before this), matching the wiki's "30% of that boosted max hit". + assertEquals(30, SoulreaperAxeDamage.minimumDamage(maxHit = 100, stacks = 5)) + } + + @Test + fun `minimum damage is zero with no stacks consumed`() { + assertEquals(0, SoulreaperAxeDamage.minimumDamage(maxHit = 100, stacks = 0)) + } + + @Test + fun `resolveDamage is zero when the accuracy roll fails`() { + val damage = + SoulreaperAxeDamage.resolveDamage( + successful = false, + maxHit = 100, + stacks = 5, + isMaxHit = false, + rollInclusive = { _, _ -> fail() }, + ) + assertEquals(0, damage) + } + + @Test + fun `resolveDamage forces the max hit when maxhit cheat is active`() { + val damage = + SoulreaperAxeDamage.resolveDamage( + successful = true, + maxHit = 100, + stacks = 5, + isMaxHit = true, + rollInclusive = { _, _ -> fail() }, + ) + assertEquals(100, damage) + } + + @Test + fun `resolveDamage rolls between the minimum floor and the max hit`() { + var capturedMin = -1 + var capturedMax = -1 + val damage = + SoulreaperAxeDamage.resolveDamage( + successful = true, + maxHit = 100, + stacks = 5, + isMaxHit = false, + rollInclusive = { lo, hi -> + capturedMin = lo + capturedMax = hi + lo + }, + ) + assertEquals(30, capturedMin) + assertEquals(100, capturedMax) + assertEquals(30, damage) + } + + private fun fail(): Nothing = throw AssertionError("unexpected call") +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt new file mode 100644 index 000000000..8ab803119 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt @@ -0,0 +1,28 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class UnleashMultipliersTest { + @Test + fun `full 100 percent energy doubles accuracy and boosts damage by 50 percent`() { + val result = UnleashMultipliers.forEnergy(1000) + assertEquals(2.0, result.accuracy, 0.0001) + assertEquals(1.5, result.maxHit, 0.0001) + } + + @Test + fun `half energy gives half the bonus`() { + val result = UnleashMultipliers.forEnergy(500) + assertEquals(1.5, result.accuracy, 0.0001) + assertEquals(1.25, result.maxHit, 0.0001) + } + + @Test + fun `only full five percent chunks of energy count`() { + // 549 is 10 whole 5% (50-energy) chunks plus a partial 11th that shouldn't count. + val result = UnleashMultipliers.forEnergy(549) + assertEquals(UnleashMultipliers.forEnergy(500).accuracy, result.accuracy, 0.0001) + assertEquals(UnleashMultipliers.forEnergy(500).maxHit, result.maxHit, 0.0001) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt new file mode 100644 index 000000000..d4d6cc2e8 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt @@ -0,0 +1,65 @@ +package org.rsmod.content.other.special.attacks.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class VestaLongswordFeintDamageTest { + @Test + fun `uses the inclusive 20 to 120 percent normal-max range`() { + assertEquals(20..120, VestaLongswordFeintDamage.range(normalMax = 100)) + } + + @Test + fun `floors both percentage endpoints`() { + assertEquals(14..87, VestaLongswordFeintDamage.range(normalMax = 73)) + } + + @Test + fun `keeps a zero maximum valid`() { + assertEquals(0..0, VestaLongswordFeintDamage.range(normalMax = 0)) + } + + /** + * Regression test for a real `::maxhit` bug: the damage roll never checked + * `player.adminMaxHit`, so the cheat silently did nothing for this weapon's spec. + */ + @Test + fun `resolveDamage forces the top of the range when maxhit cheat is active`() { + val damage = + VestaLongswordFeintDamage.resolveDamage( + range = 20..120, + isMaxHit = true, + rollInclusive = { fail() }, + ) + assertEquals(120, damage) + } + + @Test + fun `resolveDamage rolls randomly when maxhit cheat is inactive`() { + var capturedRange: IntRange? = null + val damage = + VestaLongswordFeintDamage.resolveDamage( + range = 20..120, + isMaxHit = false, + rollInclusive = { range -> + capturedRange = range + range.first + }, + ) + assertEquals(20..120, capturedRange) + assertEquals(20, damage) + } + + @Test + fun `resolveDamage returns the single value directly when the range has no spread`() { + val damage = + VestaLongswordFeintDamage.resolveDamage( + range = 0..0, + isMaxHit = false, + rollInclusive = { fail() }, + ) + assertEquals(0, damage) + } + + private fun fail(): Nothing = throw AssertionError("unexpected call") +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt new file mode 100644 index 000000000..053a4292f --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt @@ -0,0 +1,37 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ArmadylCrossbowSpecialDamageTest { + @Test + fun armadylDoublesEachBoltTypesBaseProcChance() { + assertEquals(0.10, ArmadylEnchantedBolt.Opal.armadylActivationChance(true), 0.00001) + assertEquals(0.12, ArmadylEnchantedBolt.Jade.armadylActivationChance(false), 0.00001) + assertEquals(0.50, ArmadylEnchantedBolt.Sapphire.armadylActivationChance(false), 0.00001) + assertEquals(0.20, ArmadylEnchantedBolt.Diamond.armadylActivationChance(false), 0.00001) + assertEquals(0.0, ArmadylEnchantedBolt.Topaz.armadylActivationChance(false), 0.00001) + } + + @Test + fun resolverCoversNormalAndDragonEnchantedBoltNames() { + assertEquals(ArmadylEnchantedBolt.Ruby, ArmadylEnchantedBolt.fromName("Ruby bolts (e)")) + assertEquals( + ArmadylEnchantedBolt.Dragonstone, + ArmadylEnchantedBolt.fromName("Dragonstone dragon bolts (e)"), + ) + assertEquals(null, ArmadylEnchantedBolt.fromName("Diamond bolts")) + } + + @Test + fun fixedBoltDamageHelpersFollowOsrsCapsAndRounding() { + assertEquals(100, ArmadylCrossbowSpecialDamage.rubyDamage(500)) + assertEquals(19, ArmadylCrossbowSpecialDamage.rubyDamage(99)) + assertEquals(9, ArmadylCrossbowSpecialDamage.rubySelfDamage(99)) + assertEquals(11, ArmadylCrossbowSpecialDamage.opalBonus(119)) + assertEquals(7, ArmadylCrossbowSpecialDamage.pearlBonus(119, fieryTarget = true)) + assertEquals(5, ArmadylCrossbowSpecialDamage.sapphirePrayerDrain(119)) + assertEquals(2, ArmadylCrossbowSpecialDamage.sapphirePrayerRestore(5)) + assertEquals(7, ArmadylCrossbowSpecialDamage.onyxHeal(31)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt new file mode 100644 index 000000000..bcb47d5e5 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt @@ -0,0 +1,38 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BallistaAnimationTest { + @Test + fun `light or heavy ballista against a player`() { + assertEquals( + "seq.ballista_special_attack", + BallistaAnimation.resolve(ornamented = false, targetIsNpc = false), + ) + } + + @Test + fun `light or heavy ballista against an npc`() { + assertEquals( + "seq.ballista_special_attack_pvn", + BallistaAnimation.resolve(ornamented = false, targetIsNpc = true), + ) + } + + @Test + fun `ornamented heavy ballista against a player`() { + assertEquals( + "seq.ballista02_special_attack", + BallistaAnimation.resolve(ornamented = true, targetIsNpc = false), + ) + } + + @Test + fun `ornamented heavy ballista against an npc`() { + assertEquals( + "seq.ballista02_special_attack_pvn", + BallistaAnimation.resolve(ornamented = true, targetIsNpc = true), + ) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt new file mode 100644 index 000000000..051a168c4 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt @@ -0,0 +1,75 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DarkBowDamageTest { + @Test + fun `descent of darkness range and multiplier match the wiki`() { + assertEquals(5..48, DarkBowDamage.DESCENT_OF_DARKNESS_RANGE) + assertEquals(1.3, DarkBowDamage.DESCENT_OF_DARKNESS_MULTIPLIER) + } + + @Test + fun `descent of dragons range and multiplier match the wiki`() { + assertEquals(8..48, DarkBowDamage.DESCENT_OF_DRAGONS_RANGE) + assertEquals(1.5, DarkBowDamage.DESCENT_OF_DRAGONS_MULTIPLIER) + } + + @Test + fun `a missed hit is zero regardless of the floor`() { + val damage = + DarkBowDamage.resolveHit( + accuracySuccess = false, + damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, + rollRawDamage = { fail() }, + ) + assertEquals(0, damage) + } + + @Test + fun `a landed hit below the floor is raised to the floor`() { + val damage = + DarkBowDamage.resolveHit( + accuracySuccess = true, + damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, + rollRawDamage = { 2 }, + ) + assertEquals(5, damage) + } + + @Test + fun `a landed hit above the cap is lowered to the cap`() { + val damage = + DarkBowDamage.resolveHit( + accuracySuccess = true, + damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, + rollRawDamage = { 90 }, + ) + assertEquals(48, damage) + } + + @Test + fun `a landed hit inside the range passes through unchanged`() { + val damage = + DarkBowDamage.resolveHit( + accuracySuccess = true, + damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, + rollRawDamage = { 30 }, + ) + assertEquals(30, damage) + } + + @Test + fun `descent of dragons uses its own higher floor`() { + val damage = + DarkBowDamage.resolveHit( + accuracySuccess = true, + damageRange = DarkBowDamage.DESCENT_OF_DRAGONS_RANGE, + rollRawDamage = { 3 }, + ) + assertEquals(8, damage) + } + + private fun fail(): Nothing = throw AssertionError("unexpected call") +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt new file mode 100644 index 000000000..7d7a9d740 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt @@ -0,0 +1,20 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class DragonCrossbowSpecialDamageTest { + @Test + fun annihilateScalesOneRawHitForPrimaryAndSecondaries() { + assertEquals(30, DragonCrossbowSpecialDamage.primary(25)) + assertEquals(20, DragonCrossbowSpecialDamage.secondary(25)) + } + + @Test + fun annihilateUsesIntegerFloorRounding() { + assertEquals(1, DragonCrossbowSpecialDamage.primary(1)) + assertEquals(0, DragonCrossbowSpecialDamage.secondary(1)) + assertEquals(0, DragonCrossbowSpecialDamage.primary(0)) + assertEquals(0, DragonCrossbowSpecialDamage.secondary(0)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt new file mode 100644 index 000000000..7dfa8718e --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt @@ -0,0 +1,24 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DragonKnifeVariantTest { + @Test + fun `plain dragon knife is not poisoned`() { + assertFalse(DragonKnifeVariant.isPoisoned("Dragon knife")) + } + + @Test + fun `poisoned variants are detected regardless of dose`() { + assertTrue(DragonKnifeVariant.isPoisoned("Dragon knife(p)")) + assertTrue(DragonKnifeVariant.isPoisoned("Dragon knife(p+)")) + assertTrue(DragonKnifeVariant.isPoisoned("Dragon knife(p++)")) + } + + @Test + fun `detection is case-insensitive`() { + assertTrue(DragonKnifeVariant.isPoisoned("DRAGON KNIFE(P++)")) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt new file mode 100644 index 000000000..16e1f3c6e --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt @@ -0,0 +1,21 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class EclipseAtlatlDamageTest { + @Test + fun convertsRemainingBurnIntoTheMinimumAndMaximumHit() { + assertEquals(3..26, EclipseAtlatlDamage.hitRange(normalMax = 20, remainingBurn = 6)) + } + + @Test + fun capsBurnConversionAtFiftyDamage() { + assertEquals(25..70, EclipseAtlatlDamage.hitRange(normalMax = 20, remainingBurn = 80)) + } + + @Test + fun keepsTheNormalZeroMinimumWithoutABurn() { + assertEquals(0..20, EclipseAtlatlDamage.hitRange(normalMax = 20, remainingBurn = 0)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt new file mode 100644 index 000000000..5cd24a679 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt @@ -0,0 +1,49 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MagicBowDamageTest { + /** + * Wiki: `Maximum Hit = floor(0.5 + (Visible Ranged Level + 10) * (Ammo Ranged Strength + 64) / + * 640)`. Hand-computed: (109 * 124 + 320) / 640 = 13836 / 640 = 21 (floor). + */ + @Test + fun `max hit matches the wiki formula at 99 ranged`() { + assertEquals(21, MagicBowDamage.maxHit(rangedLevel = 99, ammoRangedStrength = 60)) + } + + @Test + fun `max hit matches the wiki formula at low ranged and strength`() { + // (11 * 64 + 320) / 640 = 1024 / 640 = 1 (floor). + assertEquals(1, MagicBowDamage.maxHit(rangedLevel = 1, ammoRangedStrength = 0)) + } + + @Test + fun `max hit ignores nothing but ranged level and ammo strength`() { + // (109 * 79 + 320) / 640 = 8931 / 640 = 13 (floor) - no gear ranged strength, prayer, or + // Slayer helmet bonus enters this formula at all, matching the wiki's stated exclusions. + assertEquals(13, MagicBowDamage.maxHit(rangedLevel = 99, ammoRangedStrength = 15)) + } + + @Test + fun `resolveDamage forces the max hit when maxhit cheat is active`() { + val damage = + MagicBowDamage.resolveDamage(maxHit = 21, isMaxHit = true) { fail() } + assertEquals(21, damage) + } + + @Test + fun `resolveDamage rolls inclusive from zero to the max hit`() { + var capturedRange: IntRange? = null + val damage = + MagicBowDamage.resolveDamage(maxHit = 21, isMaxHit = false) { range -> + capturedRange = range + range.last + } + assertEquals(0..21, capturedRange) + assertEquals(21, damage) + } + + private fun fail(): Nothing = throw AssertionError("unexpected call") +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt new file mode 100644 index 000000000..f89d873e5 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt @@ -0,0 +1,24 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MorrigansJavelinBleedDamageTest { + @Test + fun `turns forty final initial damage into three ten damage bleed ticks`() { + assertEquals(30, MorrigansJavelinBleedDamage.totalDamage(40)) + assertEquals(listOf(10, 10, 10), MorrigansJavelinBleedDamage.ticks(40)) + } + + @Test + fun `uses the exact final partial tick`() { + assertEquals(11, MorrigansJavelinBleedDamage.totalDamage(15)) + assertEquals(listOf(10, 1), MorrigansJavelinBleedDamage.ticks(15)) + } + + @Test + fun `floors fractional bleed budgets and ignores zero damage`() { + assertEquals(emptyList(), MorrigansJavelinBleedDamage.ticks(1)) + assertEquals(emptyList(), MorrigansJavelinBleedDamage.ticks(0)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt new file mode 100644 index 000000000..257199207 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt @@ -0,0 +1,50 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class MorrigansThrowingAxeDamageTest { + @Test + fun `uses the Deadman 20 to 120 percent normal-max range`() { + assertEquals( + 20..120, + MorrigansThrowingAxeDamage.range( + normalMax = 100, + minimumPercent = 20, + maximumPercent = 120, + ), + ) + } + + @Test + fun `uses the Bounty Hunter 50 to 150 percent normal-max range`() { + assertEquals( + 50..150, + MorrigansThrowingAxeDamage.range( + normalMax = 100, + minimumPercent = 50, + maximumPercent = 150, + ), + ) + } + + @Test + fun `floors percentage endpoints and retains a valid zero range`() { + assertEquals( + 14..87, + MorrigansThrowingAxeDamage.range( + normalMax = 73, + minimumPercent = 20, + maximumPercent = 120, + ), + ) + assertEquals( + 0..0, + MorrigansThrowingAxeDamage.range( + normalMax = 0, + minimumPercent = 50, + maximumPercent = 150, + ), + ) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt new file mode 100644 index 000000000..829dd89fc --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt @@ -0,0 +1,19 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SoulshotDamageTest { + @Test + fun `uses only visible ranged level and arrow ranged strength`() { + assertEquals(1, SoulshotDamage.maxHit(1, 7)) + assertEquals(20, SoulshotDamage.maxHit(99, 55)) + assertEquals(21, SoulshotDamage.maxHit(99, 60)) + } + + @Test + fun `rounds with the official half-up term`() { + assertEquals(7, SoulshotDamage.maxHit(50, 7)) + assertEquals(13, SoulshotDamage.maxHit(75, 36)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt new file mode 100644 index 000000000..6f0781fa0 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt @@ -0,0 +1,23 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class TonalzticsOfRalosSpecialDamageTest { + @Test + fun allTonalzticsHitsUseSeventyFivePercentOfTheNormalMaximum() { + assertEquals(0, TonalzticsOfRalosSpecialDamage.maxHit(0)) + assertEquals(0, TonalzticsOfRalosSpecialDamage.maxHit(1)) + assertEquals(3, TonalzticsOfRalosSpecialDamage.maxHit(4)) + assertEquals(74, TonalzticsOfRalosSpecialDamage.maxHit(99)) + assertEquals(75, TonalzticsOfRalosSpecialDamage.maxHit(100)) + } + + @Test + fun divisionDrainsTenPercentOfCurrentMagicWithIntegerFlooring() { + assertEquals(0, TonalzticsOfRalosSpecialDamage.defenceDrain(0)) + assertEquals(0, TonalzticsOfRalosSpecialDamage.defenceDrain(9)) + assertEquals(1, TonalzticsOfRalosSpecialDamage.defenceDrain(10)) + assertEquals(30, TonalzticsOfRalosSpecialDamage.defenceDrain(300)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt new file mode 100644 index 000000000..2fcb2a3b1 --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt @@ -0,0 +1,14 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ToxicBlowpipeDamageTest { + @Test + fun toxicSiphonHealsHalfOfTheProspectiveRawDamage() { + assertEquals(10, ToxicBlowpipeSpecialDamage.heal(20)) + assertEquals(2, ToxicBlowpipeSpecialDamage.heal(5)) + assertEquals(0, ToxicBlowpipeSpecialDamage.heal(0)) + assertEquals(0, ToxicBlowpipeSpecialDamage.heal(-1)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt new file mode 100644 index 000000000..ca0b1255a --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt @@ -0,0 +1,17 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class WebweaverBowDamageTest { + @Test + fun swarmCapsEachHitAtCeilingOfFortyPercentOfNormalMaximum() { + assertEquals(0, WebweaverBowSpecialDamage.maxHit(0)) + assertEquals(1, WebweaverBowSpecialDamage.maxHit(1)) + assertEquals(1, WebweaverBowSpecialDamage.maxHit(2)) + assertEquals(2, WebweaverBowSpecialDamage.maxHit(3)) + assertEquals(2, WebweaverBowSpecialDamage.maxHit(5)) + assertEquals(3, WebweaverBowSpecialDamage.maxHit(6)) + assertEquals(4, WebweaverBowSpecialDamage.maxHit(10)) + } +} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt new file mode 100644 index 000000000..57329f90c --- /dev/null +++ b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt @@ -0,0 +1,31 @@ +package org.rsmod.content.other.special.attacks.ranged + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class ZaryteCrossbowSpecialDamageTest { + @Test + fun rubyBoltUsesTheZarytePassivePercentageAndCap() { + assertEquals(0, ZaryteCrossbowSpecialDamage.rubyDamage(0)) + assertEquals(21, ZaryteCrossbowSpecialDamage.rubyDamage(99)) + assertEquals(110, ZaryteCrossbowSpecialDamage.rubyDamage(1_000)) + assertEquals(9, ZaryteCrossbowSpecialDamage.rubySelfDamage(99)) + } + + @Test + fun boostedBoltValuesUseOfficialIntegerFloors() { + assertEquals(11, ZaryteCrossbowSpecialDamage.opalBonus(99)) + assertEquals(7, ZaryteCrossbowSpecialDamage.pearlBonus(99, fieryTarget = true)) + assertEquals(5, ZaryteCrossbowSpecialDamage.pearlBonus(99, fieryTarget = false)) + assertEquals(21, ZaryteCrossbowSpecialDamage.dragonstoneBonus(99)) + assertEquals(5, ZaryteCrossbowSpecialDamage.sapphirePrayerDrain(99)) + assertEquals(2, ZaryteCrossbowSpecialDamage.sapphirePrayerRestore(5)) + } + + @Test + fun boostedMaximumHitAndPoisonValuesMatchThePassive() { + assertEquals(1.26, ZaryteCrossbowSpecialDamage.DIAMOND_MAX_HIT_MULTIPLIER) + assertEquals(1.32, ZaryteCrossbowSpecialDamage.ONYX_MAX_HIT_MULTIPLIER) + assertEquals(6, ZaryteCrossbowSpecialDamage.EMERALD_POISON_DAMAGE) + } +} 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..4a7aa2cca 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,15 @@ 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.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(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/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..752ab4435 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/WebweaverBowWeapons.kt @@ -0,0 +1,136 @@ +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) + // Drawback spotanim - confirmed correct live. Left alone. + spotanim(LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) + + // Reusing the special's fx_webweaver01_launch/_impact pair here made every normal + // attack look like the special was firing - reverted back to the plain arrow. There + // are also `_launch02`/`_travel02` variants of the wild_cave_bow_arrow family never + // tried yet - possibly worth trying next, but not applied here without confirmation. + 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..ae5035fc0 --- /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.worn.HeldEquipResult +import org.rsmod.api.player.output.mes +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..a4e14d669 --- /dev/null +++ b/content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/BlowpipeCharging.kt @@ -0,0 +1,203 @@ +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. + * + * The registration order for `onOpHeldU` here is (pipe, ammo), but live testing found only "use + * blowpipe on scales" actually triggers the interaction - "use scales on blowpipe" does nothing, + * even though this framework's own `onOpHeldU` doc claims click order shouldn't matter. Not chased + * further since the working direction is confirmed functional; may be worth revisiting. + */ +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/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/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt b/content/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt new file mode 100644 index 000000000..08988948b --- /dev/null +++ b/content/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt @@ -0,0 +1,18 @@ +package org.rsmod.content.other.special.weapons.melee + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SoulreaperStackGainTest { + @Test + fun `each attack generates one more stack`() { + assertEquals(1, SoulreaperStackGain.nextStackCount(current = 0, max = 5)) + assertEquals(4, SoulreaperStackGain.nextStackCount(current = 3, max = 5)) + } + + @Test + fun `stacks cap at the maximum and do not overflow`() { + assertEquals(5, SoulreaperStackGain.nextStackCount(current = 4, max = 5)) + assertEquals(5, SoulreaperStackGain.nextStackCount(current = 5, max = 5)) + } +} 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..377d69df3 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 @@ -140,6 +140,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 +490,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/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() } From 29f93c5e6d194abedc1e16cd71b63ebc6d1c9419 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:52:59 -0400 Subject: [PATCH 02/21] fix(special-attacks): correct hit/cast spotanim height on 21 weapons height = 96 was blind-copied from a Dragon claws template into most special attacks in this content pack, regardless of whether the effect actually belonged that high. Live-tested every affected weapon on special-attacks-pr: 21 read correctly at height = 0 (this commit), 15 were confirmed correct at the original 96 and left untouched (net-zero diff after round-tripping). Confirmed correct at height = 0: axe-boost group (StatBoostSpecialAttacks), Abyssal bludgeon, Ancient mace, Barrelchest anchor, Dragon claws, Dragon hasta, Dual macuahuitl, Rune claws, Statius warhammer, Vampyre flail, Vesta spear, Voidwaker, Armadyl crossbow, Ballista, Dragon crossbow, Eclipse atlatl, Morrigan's javelin, Webweaver bow, Zaryte crossbow. Confirmed correct at 96, untouched: Abyssal whip, Dragon dagger, Dragon longsword, Dragon mace, Dragon scimitar, Dragon sword, Magic bow, Magic shortbow, Dark bow, Seercull, Rune thrownaxe, Dragon thrownaxe, Saradomin sword, Saradomin blessed sword. Still unverified (left at 0 by default, not yet tested either way): Noxious halberd (no poison test yet), Brine sabre (needs underwater test), Dogsword (blocked by a crash before the animation even plays). Separate bugs found, not fixed here: Dogsword statHeal coerceIn crash; missing animations on Saradomin sword/blessed sword, Dragon crossbow, Magic bow/shortbow; wrong projectiles on Dark bow/Magic shortbow/ Seercull; Blowpipe normal-attack anim position; ranged weapons requiring melee distance to fire their special. Co-Authored-By: Claude Sonnet 5 --- .../special/attacks/melee/AbyssalBludgeonSpecialAttack.kt | 2 +- .../other/special/attacks/melee/AncientMaceSpecialAttack.kt | 2 +- .../special/attacks/melee/BarrelchestAnchorSpecialAttack.kt | 2 +- .../other/special/attacks/melee/BrineSabreSpecialAttack.kt | 2 +- .../other/special/attacks/melee/DogswordSpecialAttack.kt | 2 +- .../other/special/attacks/melee/DragonClawsSpecialAttack.kt | 2 +- .../other/special/attacks/melee/DragonHastaSpecialAttack.kt | 6 +++--- .../special/attacks/melee/DualMacuahuitlSpecialAttack.kt | 2 +- .../special/attacks/melee/NoxiousHalberdSpecialAttack.kt | 2 +- .../other/special/attacks/melee/RuneClawsSpecialAttack.kt | 2 +- .../special/attacks/melee/StatiusWarhammerSpecialAttack.kt | 2 +- .../special/attacks/melee/VampyreFlailSpecialAttack.kt | 2 +- .../other/special/attacks/melee/VestaSpearSpecialAttack.kt | 2 +- .../other/special/attacks/melee/VoidwakerSpecialAttack.kt | 4 ++-- .../special/attacks/ranged/ArmadylCrossbowSpecialAttack.kt | 2 +- .../other/special/attacks/ranged/BallistaSpecialAttack.kt | 4 ++-- .../special/attacks/ranged/DragonCrossbowSpecialAttack.kt | 2 +- .../special/attacks/ranged/EclipseAtlatlSpecialAttack.kt | 4 ++-- .../special/attacks/ranged/MorrigansJavelinSpecialAttack.kt | 2 +- .../special/attacks/ranged/WebweaverBowSpecialAttack.kt | 4 ++-- .../special/attacks/ranged/ZaryteCrossbowSpecialAttack.kt | 4 ++-- 21 files changed, 28 insertions(+), 28 deletions(-) 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 index 5ee86f664..f0fd5eda0 100644 --- 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 @@ -44,7 +44,7 @@ class AbyssalBludgeonSpecialAttack : SpecialAttackMap { target.spotanim( spot = "spotanim.abyssal_miasma_spotanim_bludgeon", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val missingPrayer = 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 index 37e219274..75d12af62 100644 --- 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 @@ -54,7 +54,7 @@ class AncientMaceSpecialAttack : SpecialAttackMap { spotanim( spot = "spotanim.slice_player_mace_special_attack_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val rawDamage = 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 index e2ee5592d..821e136ad 100644 --- 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 @@ -53,7 +53,7 @@ class BarrelchestAnchorSpecialAttack : SpecialAttackMap { anim("seq.brain_player_anchor_special_attack") spotanim( spot = "spotanim.brain_anchor_special_attack_spot", - height = 96, + height = 0, ) val damage = manager.rollMeleeDamage( 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 index 082d36ee3..f81f3866a 100644 --- 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 @@ -50,7 +50,7 @@ class BrineSabreSpecialAttack : SpecialAttackMap { spotanim( spot = "spotanim.olaf2_brine_sabre_special_spot", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val damage = 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 index bcb8928b8..c3734a899 100644 --- 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 @@ -73,7 +73,7 @@ constructor(private val sacrifice: AncientGodswordBloodSacrifice) : SpecialAttac spotanim( spot = "spotanim.league_5_godsword_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val damage = manager.rollMeleeDamage( 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 index 473ee4a96..c5081aaed 100644 --- 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 @@ -56,7 +56,7 @@ class DragonClawsSpecialAttack : SpecialAttackMap { spotanim( spot = "spotanim.dragon_claws_spot", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val maxHit = 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 index 751f54954..28c5553b9 100644 --- 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 @@ -96,7 +96,7 @@ constructor( spotanim( spot = "spotanim.dragon_hasta_spec_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val damage = manager.rollMeleeDamage( @@ -156,7 +156,7 @@ constructor( spotanim( spot = "spotanim.sp_attack_shove_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) // Human sequence 1066 is invalid for non-human NPC skeletons. // NPC targets use their cache-defined defend animation; PvP players @@ -169,7 +169,7 @@ constructor( target.spotanim( spot = "spotanim.stunned_shove", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) stuns.applyStun(target, STUN_CYCLES) 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 index 5dd1dcc82..55ed59b99 100644 --- 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 @@ -64,7 +64,7 @@ class DualMacuahuitlSpecialAttack @Inject constructor(private val random: GameRa spotanim( spot = "spotanim.special_dual_macuahuitl_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val normalMax = 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 index 83192c624..13a3dcc63 100644 --- 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 @@ -33,7 +33,7 @@ class NoxiousHalberdSpecialAttack : SpecialAttackMap { spotanim( spot = "spotanim.vfx_noxious_halberd_spec", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) NoxiousHalberdVirulence.activate(player, damage) return true 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 index cb76382df..c0a6cd1eb 100644 --- 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 @@ -42,7 +42,7 @@ class RuneClawsSpecialAttack : SpecialAttackMap { spotanim( spot = "spotanim.sp_attack_impale_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) // Rune claws normally attack every four cycles; Impale is a five-cycle attack. 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 index b62a4d709..24e858dd3 100644 --- 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 @@ -55,7 +55,7 @@ class StatiusWarhammerSpecialAttack : SpecialAttackMap { spotanim( spot = "spotanim.statius_hammer_sa_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val successful = manager.rollMeleeAccuracy( 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 index 6621b69cf..75e197fca 100644 --- 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 @@ -153,7 +153,7 @@ private suspend fun ProtectedAccess.playRetainerVisuals( spotanim( spot = if (hallowed) HALLOWED_RETAINER_CAST_SPOT else RETAINER_CAST_SPOT, slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val projectile = manager.spawnProjectile( 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 index b0c4674a2..f03641d75 100644 --- 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 @@ -68,7 +68,7 @@ constructor( player.anim(RSCM.getReverseMapping(RSCMType.SEQ, VESTA_SPEAR_WALL_ANIMATION)) player.spotanim( spot = RSCM.getReverseMapping(RSCMType.SPOTANIM, VESTA_SPEAR_WALL_SPOTANIM), - height = 96, + height = 0, slot = constants.spotanim_slot_combat, ) VestaSpearCombatImmunity.activate(player) 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 index 246f0d9e7..7ff2a4d9c 100644 --- 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 @@ -49,11 +49,11 @@ class VoidwakerSpecialAttack : SpecialAttackMap { spotanim( spot = "spotanim.fx_voidwaker02_special", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) target.spotanim( spot = "spotanim.fx_voidwaker_impact", - height = 96, + height = 0, slot = constants.spotanim_slot_combat, ) 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 index cc618a172..d036d974d 100644 --- 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 @@ -262,7 +262,7 @@ internal enum class ArmadylEnchantedBolt( */ fun applyEffect(source: Player, target: PathingEntity, damage: Int) { if (damage > 0) { - target.spotanim(spot = spotanim, height = 96) + target.spotanim(spot = spotanim, height = 0) } when (this) { Jade -> { 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 index af4bc5f61..845d70358 100644 --- 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 @@ -67,13 +67,13 @@ class BallistaSpecialAttack @Inject constructor(private val ammunition: RangedAm anim(BallistaAnimation.resolve(ornamented = ornamented, targetIsNpc = target is Npc)) weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } - spotanim("spotanim.ballista_special", height = 96, slot = constants.spotanim_slot_combat) + 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 = 96, slot = constants.spotanim_slot_combat) + spotanim(launchSpot, height = 0, slot = constants.spotanim_slot_combat) val projectile = manager.spawnProjectile( 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 index bc8b8f481..6e8f950d9 100644 --- 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 @@ -92,7 +92,7 @@ constructor( } spotanim( launchSpotanim, - height = 96, + height = 0, slot = constants.spotanim_slot_combat, ) 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 index 2f010b1e8..047dddde3 100644 --- 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 @@ -69,7 +69,7 @@ constructor( spotanim( spot = "spotanim.special_atlatl_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) val normalMax = @@ -112,7 +112,7 @@ constructor( target.spotanim( spot = "spotanim.special_atlatl_impact_spotanim", slot = constants.spotanim_slot_combat, - height = 96, + height = 0, ) manager.continueCombat(this, target) return true 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 index 9450469d5..b61e3a9d1 100644 --- 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 @@ -90,7 +90,7 @@ constructor( anim(MORRIGANS_JAVELIN_SEQUENCE) spotanim( spot = MORRIGANS_JAVELIN_LAUNCH_SPOTANIM, - height = 96, + height = 0, slot = constants.spotanim_slot_combat, ) val projectile = 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 index 444e7f700..2aab8e692 100644 --- 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 @@ -73,7 +73,7 @@ constructor( weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } spotanim( SWARM_LAUNCH_SPOTANIM, - height = 96, + height = 0, slot = constants.spotanim_slot_combat, ) @@ -110,7 +110,7 @@ constructor( target.spotanim( SWARM_IMPACT_SPOTANIM, delay = hit.clientDelay, - height = 96, + height = 0, ) if (index == 0) { manager.queueRangedHit( 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 index 74681d728..7a0e0894c 100644 --- 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 @@ -214,7 +214,7 @@ private fun ArmadylEnchantedBolt.zaryteApplyEffect( if (damage <= 0) { return } - target.spotanim(spot = spotanim, height = 96) + target.spotanim(spot = spotanim, height = 0) val requested = ZaryteCrossbowSpecialDamage.sapphirePrayerDrain(source.rangedLvl) val drained = min(requested, player.prayerLvl) if (drained <= 0) { @@ -231,7 +231,7 @@ private fun ArmadylEnchantedBolt.zaryteApplyEffect( ArmadylEnchantedBolt.Emerald -> { val player = target as? Player ?: return if (damage > 0) { - target.spotanim(spot = spotanim, height = 96) + target.spotanim(spot = spotanim, height = 0) CombatEffects.poison(player, ZaryteCrossbowSpecialDamage.EMERALD_POISON_DAMAGE) } } From 2a4d66424cdd63bdc66457dd838d132e74bba849 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:38:10 -0400 Subject: [PATCH 03/21] fix(special-attacks): Dogsword NPC freeze, Morrigan's javelin range, Belle's folly, cache-lock retry - Dogsword ("Power of the Gods"): freeze effect was Player-only (`(target as? Player)?.let {...}`), silently no-oping on NPCs. Wiki confirms Zamorak godsword's freeze is a real PvM tool (Muttadiles, Barrows' Dharok), not player-only - wired through BindEffectService (Npc.movementLocked) to match the real Zamorak godsword's own existing pattern in ImpactMeleeSpecialAttacks.kt. Both files also now play spotanim.ice_barrage_impact on freeze ("Freezes opponent... with a similar animation to Ice Barrage" per wiki), which neither had before. - PlayerStatExtensions.statHeal: coerceIn(current, base) crashed with "Cannot coerce value to an empty range" whenever current was already boosted above base (e.g. Dogsword healing a player who was already overhealed) - coerceIn(current, maxOf(current, base)) instead, a shared fix covering every heal-based special, not just Dogsword. - obj.morrigans_javelin was missing param.attack_range entirely (fell back to melee-only range=1); both cache variants (br_morrigans_javelin, morrigans_javelin_bh) already had it set to 5, the base item just never got it. - Added obj.belles_folly's weapon definition (weaponCategory + attack animations/sounds) - it had none at all. Wiki confirms its attack animation is identical to the Ghrazi rapier, so this reuses that entry's exact seqs/sounds/category. - or-cache buildCache: CacheTool.initialize() (external dev.or2:tools dependency) intermittently failed to overwrite SERVER/main_file_cache.idx255 while copying from LIVE (FileAlreadyExistsException) - confirmed as an external OS-level lock (Windows Defender/Search Indexer), not anything JVM-held, since it persisted with zero JVM processes running. Added a retry-with-backoff around that call since we can't patch the external library directly. VISUAL_QA.md added to track the broader live in-game verification pass this covers. --- .data/raw-cache/server/items.toml | 27 ++++++ .../api/player/stat/PlayerStatExtensions.kt | 8 +- content/other/special-attacks/VISUAL_QA.md | 87 +++++++++++++++++++ .../attacks/melee/DogswordSpecialAttack.kt | 18 +++- .../melee/ImpactMeleeSpecialAttacks.kt | 2 + .../main/kotlin/dev/openrune/CacheTools.kt | 28 +++++- 6 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 content/other/special-attacks/VISUAL_QA.md diff --git a/.data/raw-cache/server/items.toml b/.data/raw-cache/server/items.toml index b1a7f0c9a..d037e6c6c 100644 --- a/.data/raw-cache/server/items.toml +++ b/.data/raw-cache/server/items.toml @@ -18703,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" @@ -19148,6 +19171,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" 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..0a6efe463 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 @@ -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/content/other/special-attacks/VISUAL_QA.md b/content/other/special-attacks/VISUAL_QA.md new file mode 100644 index 000000000..8d57cd715 --- /dev/null +++ b/content/other/special-attacks/VISUAL_QA.md @@ -0,0 +1,87 @@ +# Special attack visual QA + +Live in-game verification pass (spec animation height, spotanim content, freeze/effect visuals). +Not the same as `PROGRESS.md` (that tracks whether the damage/effect logic is implemented at all - +this tracks whether it *looks* right). Check items off as you verify them; leave a note inline if +something's still wrong so we don't lose track between sessions. + +## Height — confirmed correct, no further action needed + +- [x] Abyssal bludgeon +- [x] Barrelchest anchor +- [x] Ancient mace +- [x] Dragon claws +- [x] Dragon hasta (height only - still missing an animation, see below) +- [x] Dual macuahuitl +- [x] Rune claws +- [x] Statius warhammer +- [x] Vesta spear +- [x] Voidwaker +- [x] Armadyl crossbow +- [x] Zaryte crossbow +- [x] Ballista (both variants) +- [x] Dragon crossbow (height only - anim still missing, see below) +- [x] Morrigan's javelin +- [x] Eclipse atlatl +- [x] Webweaver bow (spec) + +## Height — reverted back to 96, confirmed correct + +- [x] Dragon/Trailblazer/3rd age/Infernal/Crystal axe boost (`StatBoostSpecialAttacks.kt`) +- [x] Abyssal whip +- [x] Dragon dagger +- [x] Dragon longsword +- [x] Dragon mace +- [x] Dragon scimitar +- [x] Dragon sword +- [x] Saradomin sword (height only - anim still missing, see below) +- [x] Saradomin blessed sword (height only - anim still missing, see below) +- [x] Dragon thrownaxe +- [x] Magic shortbow (height only - anim/projectile still wrong, see below) +- [x] Magic bow (height only - anim still wrong, see below) +- [x] Dark bow (height only - projectile still wrong, see below) +- [x] Seercull (height only - projectile still wrong, see below) +- [x] Rune thrownaxe + +## Height — still untested + +- [ ] Brine sabre (needs underwater to test) +- [ ] Dogsword (height itself - the `statHeal` crash is now fixed, so this should be testable now) +- [ ] Noxious halberd (needs you poisoned to trigger) +- [ ] Vampyre flail + +## Animation / effect content bugs (separate from height, not yet fixed) + +- [ ] Saradomin sword - animation missing entirely +- [ ] Saradomin blessed sword - animation missing entirely +- [ ] Dragon crossbow - special attack animation missing entirely +- [ ] Magic bow (longbow) - animation messed up +- [ ] Magic shortbow - animation messed up + fires wrong projectile. Tried delaying the target-hit + spotanim to the projectile's arrival (it was firing instantly at cast time, before the + projectiles even existed) - tested live, felt worse, reverted. `proj_type` sourcing checked + out fine (matches every other ranged file). No dedicated "green arrow" asset found in the + cache for this weapon. You described it as: a green launch flash, then two plain-colored + arrows - that sequence may be correct/intentional (Magic shortbow fires your real ammo, not a + magic arrow; the green is presumably just the bow's own muzzle-flash), so the actual bug (if + any) is still unidentified. Needs a fresh look, ideally with more specific detail on which part + looks wrong (timing? color? something firing that shouldn't?). +- [ ] Dark bow - fires wrong projectile +- [ ] Seercull - fires wrong projectile +- [ ] Dragon hasta - missing an animation (something like Sunspear's thrust) +- [x] Dogsword - `statHeal` crash fixed (`coerceIn(current, base)` could have `current > base` - + already-boosted stat - and threw; now `coerceIn(current, maxOf(current, base))`, a shared fix + in `PlayerStatExtensions.kt` that covers every heal-based special, not just Dogsword) +- [x] Dogsword - freeze now works on NPCs too, not just players (`BindEffectService`, matches the + real Zamorak godsword's own `ImpactMeleeSpecialAttacks.kt`) +- [x] Dogsword + Zamorak godsword - both now play `spotanim.ice_barrage_impact` on freeze (wiki: + "similar animation to Ice Barrage") - **needs your confirmation it actually shows now** + +## Flagged, out of scope for this pass + +- [ ] Blowpipe (normal attack, not special) - animation renders from the player instead of the pipe +- [x] Morrigan's javelin - forced to melee distance, fixed: `obj.morrigans_javelin` was missing + `param.attack_range` entirely in `items.toml` (fell back to melee range=1). Both cache variants + (`br_morrigans_javelin`, `morrigans_javelin_bh`) already had it set to 5 - just the base item + never got it. Added `attack_range=5` to match. Checked Rune/Dragon thrownaxe too - both already + had `attack_range` set correctly, so this wasn't a broader thrown-weapon pattern, just this one + item. **Needs your confirmation it actually ranges properly now.** 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 index c3734a899..2fe8d2fa2 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -27,13 +28,17 @@ import org.rsmod.game.hit.HitType */ class DogswordSpecialAttack @Inject -constructor(private val sacrifice: AncientGodswordBloodSacrifice) : SpecialAttackMap { +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, ), @@ -43,6 +48,7 @@ constructor(private val sacrifice: AncientGodswordBloodSacrifice) : SpecialAttac PowerOfTheGods( manager = manager, sacrifice = sacrifice, + binds = binds, bloodSacrificeDamage = DEADMAN_BLOOD_SACRIFICE_DAMAGE, bloodSacrificeHealCap = DEADMAN_BLOOD_SACRIFICE_HEAL_CAP, ), @@ -52,6 +58,7 @@ constructor(private val sacrifice: AncientGodswordBloodSacrifice) : SpecialAttac private class PowerOfTheGods( private val manager: SpecialAttackManager, private val sacrifice: AncientGodswordBloodSacrifice, + private val binds: BindEffectService, private val bloodSacrificeDamage: Int, private val bloodSacrificeHealCap: Int, ) : MeleeSpecialAttack { @@ -101,7 +108,14 @@ constructor(private val sacrifice: AncientGodswordBloodSacrifice) : SpecialAttac constant = max(SARADOMIN_MINIMUM_PRAYER_HEAL, (damage + 3) / 4), percent = 0, ) - (target as? Player)?.let { CombatEffects.freeze(it, ZAMORAK_FREEZE_TICKS) } + // 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, 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 index 526600ac5..8b1c9abc1 100644 --- 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 @@ -239,6 +239,8 @@ constructor(private val binds: BindEffectService) : SpecialAttackMap { // 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) 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() { From a92aaf7c47eaf611903cdde5cd4716bfea9fb7fc Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:38:10 -0400 Subject: [PATCH 04/21] fix(special-attacks): rewrite Magic shortbow Snapshot visuals from cache data Previous attempts at this file guessed at spotanim assets iteratively based on live-test feedback alone, without ever confirming against the underlying cache/animation data. Rewriting from the actual data instead: - seq.snapshot (1074) is confirmed as the real spec animation (matches AnimationID.SNAPSHOT / RANGED_MAGIC_SHORTBOW_SPEC referenced by several RuneLite plugins). Its frame data is two identical 27-client-cycle draw-and-release cycles back to back - the second arrow looses 27 client cycles after the first, not a full server tick apart. - spotanim.sp_attack_snapshot_spotanim (256) sits in the original RS2 spec-graphic id block (246-258) alongside sp_attack_puncture/cleave/shatter, all of which play on the attacker, and its own internal animation is a single draw-glow, not an impact effect. It belongs on the player once per shot, not on the target - putting it on the target (the previous version) was live-confirmed as the "weird effect on hit" bug. - spotanim.sp_attack_glow_arrow_travel (249) is what makes the arrow glow while actually in flight, confirmed live after an earlier version left the ammo's plain travel colour in place. - The second arrow's visual separation (both launch spotanim and ProjAnim) comes from a 27-client- cycle offset via ProjAnim.copy(), not a projanim swap (Dark bow's doublearrow_one/doublearrow_two pair has different angle/stepMultiplier in projectiles.toml - an intentional high/low-arc effect, confirmed wrong for this weapon) and not a delay() (whole-tick granularity, far too slow live). - Both hits register on the first arrow's tick (hitDelay/dropDelay both use firstProjectile's timing) - the second arrow's offset is visual only, not a real gameplay delay between the hits. Co-Authored-By: Claude Sonnet 5 --- content/other/special-attacks/VISUAL_QA.md | 25 +++-- .../ranged/MagicShortbowSpecialAttack.kt | 99 +++++++++++++++---- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/content/other/special-attacks/VISUAL_QA.md b/content/other/special-attacks/VISUAL_QA.md index 8d57cd715..236a57678 100644 --- a/content/other/special-attacks/VISUAL_QA.md +++ b/content/other/special-attacks/VISUAL_QA.md @@ -56,15 +56,22 @@ something's still wrong so we don't lose track between sessions. - [ ] Saradomin blessed sword - animation missing entirely - [ ] Dragon crossbow - special attack animation missing entirely - [ ] Magic bow (longbow) - animation messed up -- [ ] Magic shortbow - animation messed up + fires wrong projectile. Tried delaying the target-hit - spotanim to the projectile's arrival (it was firing instantly at cast time, before the - projectiles even existed) - tested live, felt worse, reverted. `proj_type` sourcing checked - out fine (matches every other ranged file). No dedicated "green arrow" asset found in the - cache for this weapon. You described it as: a green launch flash, then two plain-colored - arrows - that sequence may be correct/intentional (Magic shortbow fires your real ammo, not a - magic arrow; the green is presumably just the bow's own muzzle-flash), so the actual bug (if - any) is still unidentified. Needs a fresh look, ideally with more specific detail on which part - looks wrong (timing? color? something firing that shouldn't?). +- [ ] Magic shortbow - rewritten from scratch off the cache data after a long run of guesswork + iterations went nowhere. What the data says: `seq.snapshot` (1074, confirmed as the real spec + anim by multiple RuneLite plugins) is two identical 27-client-cycle draw-and-release cycles + back to back, so the second arrow looses 27 cycles (~0.9 tick) after the first. + `sp_attack_snapshot_spotanim` (256) is Snapshot's only graphic - it's in the RS2 spec-graphic + block (246-258) with `sp_attack_puncture`/`cleave`/`shatter`, all *attacker*-side, and its + own anim is a single 21-cycle draw glow. So it's the player's per-draw launch glow, NOT a + target-hit effect - the old code put it on the target, which was the "arrow appears on the + enemy / weird thing on hit" you saw. The unnamed `glow_arrow_launch/travel` pair (249/250) + belongs to Powershot/Soulshot by elimination, not Snapshot; arrows in flight are the worn + ammo's normal `proj_travel`. Implementation: glow on player at delay 0 and again at delay 27 + (second in a different spotanim slot so it doesn't overwrite the first), second projectile is + a `ProjAnim.copy` of the first with `startTime`/`endTime` +27 (same speed/arc; two + byte-identical projectiles in one tick render as one). Dark bow's `doublearrow_one/two` was + ruled out via projectiles.toml (different angle/stepMultiplier = intentional high/low arc). + **Needs live confirmation.** - [ ] Dark bow - fires wrong projectile - [ ] Seercull - fires wrong projectile - [ ] Dragon hasta - missing an animation (something like Sunspear's thrust) 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 index 7e227843d..5108ca937 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -26,11 +27,32 @@ import org.rsmod.game.type.getOrNull * 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, all taken from the cache rather than inferred: + * - `seq.snapshot` (1074) is the spec animation (confirmed by several RuneLite plugins that key + * off it as `RANGED_MAGIC_SHORTBOW_SPEC`). It is two identical draw-and-release cycles back to + * back, 27 client cycles each (frame delays 2,2,2,2,8,5,2,2,2,2, twice) - a sped-up double of + * the normal `human_bow` (426) draw. The second arrow is therefore loosed 27 client cycles after + * the first: within the same server tick, not a tick later. + * - `spotanim.sp_attack_snapshot_spotanim` (256) is Snapshot's only *attacker*-side graphic. It + * sits in the original RS2 spec-graphic block (246-258) alongside `sp_attack_puncture`/`cleave`/ + * `shatter`, all of which play on the caster, and its own animation (1075) is a single 21-cycle + * draw glow. It is the player's per-draw launch glow, one per arrow - not a target-hit effect + * (an earlier version put it on the target; live testing confirmed that read as a wrong effect + * on hit, not a launch). + * - Live testing also confirmed the in-flight arrow itself needs to glow, not just the draw - + * `spotanim.sp_attack_glow_arrow_travel` (249) overrides the ammo's own `proj_travel` for both + * shots. (An earlier version assumed this pair belonged to Powershot/Soulshot by elimination and + * left the ammo's plain colour in flight; that guess was wrong.) */ -class MagicShortbowSpecialAttack @Inject constructor(private val ammunition: RangedAmmoManager) : - SpecialAttackMap { +class MagicShortbowSpecialAttack +@Inject +constructor( + private val ammunition: RangedAmmoManager, + private val worldRepo: WorldRepository, +) : SpecialAttackMap { override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { - val snapshot = Snapshot(manager, ammunition) + val snapshot = Snapshot(manager, ammunition, worldRepo) registerRanged("obj.magic_shortbow", snapshot) registerRanged("obj.magic_shortbow_i", snapshot) registerRanged("obj.br_magic_bow", snapshot) @@ -39,6 +61,7 @@ class MagicShortbowSpecialAttack @Inject constructor(private val ammunition: Ran private class Snapshot( private val manager: SpecialAttackManager, private val ammunition: RangedAmmoManager, + private val worldRepo: WorldRepository, ) : RangedSpecialAttack { override suspend fun ProtectedAccess.attack( target: Npc, @@ -75,27 +98,44 @@ class MagicShortbowSpecialAttack @Inject constructor(private val ammunition: Ran return false } - anim("seq.snapshot") + anim(SNAPSHOT_SEQUENCE) weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } - target.spotanim( - "spotanim.sp_attack_snapshot_spotanim", + + // 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 the old 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 = constants.spotanim_slot_combat, + slot = SECOND_GLOW_SPOTANIM_SLOT, ) - // Matches PvNCombat's own standard ranged-attack handling: the launch spotanim plays - // on the shooter (a muzzle-flash-style effect), not the target, and is genuinely - // absent for some ammo (no proj_launch param) - null is a real, expected case here. - val launchSpotanim = - quiverType.paramOrNull(params.proj_launch_double) - ?: quiverType.paramOrNull(params.proj_launch) - val launchSpotanimName = - launchSpotanim?.let { RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) } - spotanim(launchSpotanimName, height = 96, slot = constants.spotanim_slot_combat) - - val travelSpot = RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id) + + // 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) and + // the attacker's draw glow (256) 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 firstProjectile = manager.spawnProjectile(this, target, travelSpot, projanim) - val secondProjectile = manager.spawnProjectile(this, target, travelSpot, projanim) + // 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) @@ -114,18 +154,22 @@ class MagicShortbowSpecialAttack @Inject constructor(private val ammunition: Ran 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 = secondProjectile.serverCycles, + dropDelay = firstProjectile.serverCycles, ) manager.queueRangedDamage( source = this, target = target, ammo = quiverType, damage = secondDamage, - hitDelay = secondProjectile.serverCycles, + hitDelay = firstProjectile.serverCycles, ) manager.giveCombatXp(this, target, attack, firstDamage + secondDamage) @@ -166,6 +210,19 @@ class MagicShortbowSpecialAttack @Inject constructor(private val ammunition: Ran } 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" + + /** + * 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 + + /** Any slot other than [constants.spotanim_slot_combat], so the two glows coexist. */ + const val SECOND_GLOW_SPOTANIM_SLOT = 0 + const val ARROWS_PER_SNAPSHOT = 2 const val SNAPSHOT_LEVEL_BONUS = 10 const val RANGED_STRENGTH_BASE = 64 From 1dc91db9d6198d499ebc35a97eb912d0b59d47eb Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:19:02 -0400 Subject: [PATCH 05/21] fix(special-attacks): sync Magic shortbow arrow launches to their own draw manager.spawnProjectile() dispatched the first arrow immediately using projanim.arrow's generic delay (41 client cycles), tuned for a normal single-draw bow shot. seq.snapshot's own draw-and-release cycle is only 27 cycles, so that default fired the arrow 14 cycles into the second draw instead of the first, and the second arrow (offset +27) landed after both draws had already finished. Build the ProjAnim by hand instead of going through spawnProjectile, so it can be dispatched at cycle 16 (where the held-draw frames end and the snap/release frames begin) rather than the wrong default. startTime and endTime are shifted by the same amount to preserve the real flight duration. Live-tested: arrows now fire alongside their own draw instead of both leaking out afterward. Co-Authored-By: Claude Sonnet 5 --- .../ranged/MagicShortbowSpecialAttack.kt | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) 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 index 5108ca937..0b34fc28f 100644 --- 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 @@ -1,6 +1,7 @@ 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 @@ -19,6 +20,7 @@ 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 @@ -121,7 +123,29 @@ constructor( // 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 firstProjectile = manager.spawnProjectile(this, target, travelSpot, projanim) + 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 @@ -220,6 +244,15 @@ constructor( */ 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 + /** Any slot other than [constants.spotanim_slot_combat], so the two glows coexist. */ const val SECOND_GLOW_SPOTANIM_SLOT = 0 From 1a66ad70c646b5113f05e8116e404e01cf743335 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:19:34 -0400 Subject: [PATCH 06/21] docs(special-attacks): mark Magic shortbow confirmed in VISUAL_QA Co-Authored-By: Claude Sonnet 5 --- content/other/special-attacks/VISUAL_QA.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/content/other/special-attacks/VISUAL_QA.md b/content/other/special-attacks/VISUAL_QA.md index 236a57678..18269afd9 100644 --- a/content/other/special-attacks/VISUAL_QA.md +++ b/content/other/special-attacks/VISUAL_QA.md @@ -56,7 +56,7 @@ something's still wrong so we don't lose track between sessions. - [ ] Saradomin blessed sword - animation missing entirely - [ ] Dragon crossbow - special attack animation missing entirely - [ ] Magic bow (longbow) - animation messed up -- [ ] Magic shortbow - rewritten from scratch off the cache data after a long run of guesswork +- [x] Magic shortbow - rewritten from scratch off the cache data after a long run of guesswork iterations went nowhere. What the data says: `seq.snapshot` (1074, confirmed as the real spec anim by multiple RuneLite plugins) is two identical 27-client-cycle draw-and-release cycles back to back, so the second arrow looses 27 cycles (~0.9 tick) after the first. @@ -71,7 +71,13 @@ something's still wrong so we don't lose track between sessions. a `ProjAnim.copy` of the first with `startTime`/`endTime` +27 (same speed/arc; two byte-identical projectiles in one tick render as one). Dark bow's `doublearrow_one/two` was ruled out via projectiles.toml (different angle/stepMultiplier = intentional high/low arc). - **Needs live confirmation.** + Live-tested and confirmed serviceable, **with one follow-up fix**: the arrows themselves were + firing on the ammo's generic `projanim.arrow` delay (41 cycles, tuned for a normal single + draw), so both fires leaked out after the compressed 27-cycle draws instead of matching them. + Built the `ProjAnim`s by hand instead of via `spawnProjectile` so the first can launch at + cycle 16 (held-draw frames end, snap/release begins) with `startTime`/`endTime` shifted + together to keep the real flight duration unchanged. Confirmed live: arrows now fire + alongside their own draw. - [ ] Dark bow - fires wrong projectile - [ ] Seercull - fires wrong projectile - [ ] Dragon hasta - missing an animation (something like Sunspear's thrust) From 5cf9a3ee2d4bc78e825a5af284f7a0f7a47951c4 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:15:10 -0400 Subject: [PATCH 07/21] fix(special-attacks): Saradomin sword pair, Dark bow overlap, Powershot/Seercull glows Saradomin sword: replaced the target-only saradomin_lightning (76) with the real graphics from a reference implementation of this exact special (Zenyte-based Offline_Scape) - caster glow dh_sword_update_saradomin_ god_special_spotanim (1213, purpose-built, applied automatically by that engine's combat framework), target godwars_saradomin_magic_attack_ spotanim (1196, height 0, 30-cycle delay). Animation (1132) was already correct. Saradomin blessed sword: shares the base sword's caster/target graphics exactly, plus a third ground-location effect at the target's tile (godwars_saradomin_light_attk_spot, 1221) the base sword doesn't have - added WorldRepository/spotanimMap support to this file for it. Damage formula was already correct. Dark bow: was spawning two overlapping projectiles per arrow - the correct special one plus a second, separate one using the ammo's own plain colour, both on the same path at once. Dropped the redundant spawn (spotanim doesn't affect ProjAnim timing, only the projanim type does). Also fixed the two hits landing a tick apart at real range (doublearrow_one/two's differing stepMultiplier, meant only for visual arc, was leaking into damage timing) - both now resolve on the first arrow's tick. Magic bow (Powershot) and Seercull (Soulshot): both were using the ammo's plain launch/travel colour instead of a dedicated effect, same pattern as Magic shortbow's earlier fix. Powershot's glow (250) found by elimination in the project's own custom graphic block. Soulshot's graphics (472/473/474) found live via a temporary raw-numeric-id mode added to ::spot, since no external source had them - to be reverted before the PR (tracked in VISUAL_QA.md). Co-Authored-By: Claude Sonnet 5 --- .../content/other/commands/AdminCommands.kt | 19 +++++- content/other/special-attacks/VISUAL_QA.md | 68 ++++++++++++++++--- .../SaradominBlessedSwordSpecialAttack.kt | 36 ++++++++-- .../melee/SaradominSwordSpecialAttack.kt | 26 ++++++- .../attacks/ranged/DarkBowSpecialAttack.kt | 48 +++++++++---- .../attacks/ranged/MagicBowSpecialAttack.kt | 18 +++-- .../attacks/ranged/SeercullSpecialAttack.kt | 19 ++++-- 7 files changed, 191 insertions(+), 43 deletions(-) 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 035ee4b82..bcc95509f 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 @@ -55,6 +55,7 @@ import org.rsmod.game.cheat.Cheat import org.rsmod.game.entity.Npc import org.rsmod.game.entity.Player import org.rsmod.game.entity.PlayerList +import org.rsmod.game.entity.util.PathingEntityCommon import org.rsmod.game.loc.LocAngle import org.rsmod.game.loc.LocEntity import org.rsmod.game.loc.LocInfo @@ -110,7 +111,7 @@ constructor( } onCommand("anim", "Play animation", ::anim) onCommand("spot", "Play spotanim", ::spotanim) { - invalidArgs = "Use as ::spot spotanimDebugNameOrId (ex: fx_emote_party01_active)" + invalidArgs = "Use as ::spot spotanimDebugNameOrId [height] (ex: fx_emote_party01_active or 157)" } onCommand("synth", "Play synth sound", ::synth) { invalidArgs = "Use as ::synth idOrName (ex: ::synth 3600 or ::synth pillory_wrong)" @@ -439,6 +440,22 @@ constructor( private fun spotanim(cheat: Cheat) = with(cheat) { + val arg = args.getOrNull(0) + if (arg == null) { + player.mes("Use as ::spot spotanimDebugNameOrId [height] (ex: fx_emote_party01_active or 157)") + return + } + // Raw numeric id, bypassing RSCM name resolution entirely - for trying out unnamed/ + // unaliased spotanims still sitting in the raw cache without a debug name yet. + val rawId = arg.toIntOrNull() + if (rawId != null) { + val height = min(args.getOrNull(1)?.toIntOrNull() ?: 0, Short.MAX_VALUE.toInt()) + PathingEntityCommon.spotanim(player, rawId, delay = 0, height = height, slot = 0) + player.mes("Spotanim: $rawId (height=$height)") + logger.debug { "Spotanim: $rawId" } + return + } + val (typeName, heightArg) = args.asTypeNameAndNumber(defaultNumber = 0) val typeId = "spotanim.${typeName}".asRSCM() if (typeId == -1) { diff --git a/content/other/special-attacks/VISUAL_QA.md b/content/other/special-attacks/VISUAL_QA.md index 18269afd9..bb96e3729 100644 --- a/content/other/special-attacks/VISUAL_QA.md +++ b/content/other/special-attacks/VISUAL_QA.md @@ -24,6 +24,8 @@ something's still wrong so we don't lose track between sessions. - [x] Morrigan's javelin - [x] Eclipse atlatl - [x] Webweaver bow (spec) +- [x] Saradomin sword (full fix, see Animation section below) +- [x] Saradomin blessed sword (full fix, see Animation section below) ## Height — reverted back to 96, confirmed correct @@ -34,13 +36,11 @@ something's still wrong so we don't lose track between sessions. - [x] Dragon mace - [x] Dragon scimitar - [x] Dragon sword -- [x] Saradomin sword (height only - anim still missing, see below) -- [x] Saradomin blessed sword (height only - anim still missing, see below) - [x] Dragon thrownaxe - [x] Magic shortbow (height only - anim/projectile still wrong, see below) - [x] Magic bow (height only - anim still wrong, see below) -- [x] Dark bow (height only - projectile still wrong, see below) -- [x] Seercull (height only - projectile still wrong, see below) +- [x] Dark bow (height only - overlap bug still wrong, see below) +- [x] Seercull (height confirmed; visuals now fully fixed too, see below) - [x] Rune thrownaxe ## Height — still untested @@ -52,10 +52,33 @@ something's still wrong so we don't lose track between sessions. ## Animation / effect content bugs (separate from height, not yet fixed) -- [ ] Saradomin sword - animation missing entirely -- [ ] Saradomin blessed sword - animation missing entirely +- [x] Saradomin sword - was only playing `saradomin_lightning` (76) on the target, nothing on the + caster. Found a real reference implementation of this exact weapon's special (Zenyte-based + `MouldyToast/Offline_Scape`, `SARADOMINS_LIGHTNING` in `SpecialAttack.java`) with the true + graphics: caster gets `dh_sword_update_saradomin_god_special_spotanim` (1213, height 0) - + purpose-built for this special, applied automatically by that engine's combat framework + before the per-weapon handler runs, not reused from anywhere else; target gets + `godwars_saradomin_magic_attack_spotanim` (1196, height 0, 30-cycle delay timed to the + swing's impact) - confirmed live independently before this reference turned up. Animation + (1132) was already correct. Confirmed live. +- [x] Saradomin blessed sword - same reference confirms it shares the caster glow (1213) and + target lightning (1196) with the base sword exactly, plus a third effect the base sword + doesn't have: a ground-location graphic at the target's own tile + (`godwars_saradomin_light_attk_spot`, 1221, height 0, 30-cycle delay) sent to a coord rather + than attached to the entity - needed adding `WorldRepository`/`spotanimMap` to this file, + which didn't have location-based spotanim support before. Damage formula (1.25x max hit, + single Magic-defence-rolled hit) was already correct - traced `rollMagicalMeleeAccuracy`'s + own doc comment to confirm it always rolls the target's Magic defence regardless of the + `attackType` param, matching the wiki exactly. **Needs your confirmation.** - [ ] Dragon crossbow - special attack animation missing entirely -- [ ] Magic bow (longbow) - animation messed up +- [x] Magic bow (longbow) - animation confirmed fine as-is: Powershot has no unique animation in + the real game either, it's just the weapon's plain normal-attack draw (`playRangedWeaponFx`). + What was actually broken was the launch/travel colour - same bug as every other special in + this pack, using the ammo's plain `proj_launch`/`proj_travel` instead of a dedicated effect. + Every id in the project's custom `sp_attack_` graphic block (246-258) was already claimed by + another special except 250 (`sp_attack_glow_arrow_launch`) - unused by elimination, and it's + Powershot's own dedicated glow. Now overrides both launch and in-flight colour with it. + Confirmed live. - [x] Magic shortbow - rewritten from scratch off the cache data after a long run of guesswork iterations went nowhere. What the data says: `seq.snapshot` (1074, confirmed as the real spec anim by multiple RuneLite plugins) is two identical 27-client-cycle draw-and-release cycles @@ -78,8 +101,29 @@ something's still wrong so we don't lose track between sessions. cycle 16 (held-draw frames end, snap/release begins) with `startTime`/`endTime` shifted together to keep the real flight duration unchanged. Confirmed live: arrows now fire alongside their own draw. -- [ ] Dark bow - fires wrong projectile -- [ ] Seercull - fires wrong projectile +- [x] Dark bow - was spawning two overlapping projectiles per arrow: the correct special one + (dragon-head/smoke, ammo-branched) plus a second, separate one using the ammo's own plain + colour, both flying the same path at once - read as "wrong arrow in the air" regardless of + ammo. Dropped the redundant plain-colour spawn entirely (confirmed via + `ProjAnim.calculateEndTime` that dropping it doesn't touch timing, since that only depends on + the projanim type, not the spotanim). Also fixed the two hits landing a tick apart at real + range (`doublearrow_one/two`'s different `stepMultiplier` for the visual arc was leaking into + damage timing) - both hits now resolve on the first arrow's tick, matching Magic shortbow's + same fix. The "smoke instead of dragon head" report turned out to be non-dragon ammo, not a + bug - `descentOfDragons()` only triggers for `category.dragon_arrow`, confirmed correctly + tagged on the item. Confirmed live. +- [x] Seercull - was using the ammo's plain launch/travel colour with no target-hit effect at all + (same "plain ammo colour" bug as every other special here). External wiki/RuneLite search for + Soulshot's real graphic id turned up nothing, so found it live instead: added a raw-numeric-id + mode to `::spot [height]` (bypasses RSCM name lookup entirely) and tried a candidate + cluster around Dagannoth Supreme's own arrow-shower graphics, since Seercull drops from it and + the real special's burst-of-spikes look matches. Confirmed live: 474 (`dagannoth_arrow_ + spotanim_hit`, height 0) is the target-hit burst, 473 (`dagannoth_arrow_spotanim_travel`) is + the in-flight arrow - both genuine, official Dagannoth Supreme graphics, reused here. 472 + (`sp_attack_glow_arrow_launch_white`) is the launch glow - turned out to be this project's own + custom addition, not from the real game, which is why it never showed up in any external + search. Animation itself confirmed fine as just the plain normal-attack draw, same as Magic + longbow's Powershot. - [ ] Dragon hasta - missing an animation (something like Sunspear's thrust) - [x] Dogsword - `statHeal` crash fixed (`coerceIn(current, base)` could have `current > base` - already-boosted stat - and threw; now `coerceIn(current, maxOf(current, base))`, a shared fix @@ -98,3 +142,9 @@ something's still wrong so we don't lose track between sessions. never got it. Added `attack_range=5` to match. Checked Rune/Dragon thrownaxe too - both already had `attack_range` set correctly, so this wasn't a broader thrown-weapon pattern, just this one item. **Needs your confirmation it actually ranges properly now.** + +## Before the PR + +- [ ] Revert the raw-numeric-id addition to `::spot` in `AdminCommands.kt` (`content/other/ + commands`) - debug-only, added to test unnamed spotanim ids live for Seercull's fix. Not + meant to ship; strip it back to name-only before this branch goes into the PR. 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 index d3bd62848..e21819686 100644 --- 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 @@ -1,9 +1,11 @@ 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 @@ -16,14 +18,18 @@ 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 : SpecialAttackMap { +class SaradominBlessedSwordSpecialAttack @Inject constructor(private val worldRepo: WorldRepository) : + SpecialAttackMap { override fun SpecialAttackRepository.register(manager: SpecialAttackManager) { - val lightning = BlessedLightning(manager) + 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) : MeleeSpecialAttack { + private class BlessedLightning( + private val manager: SpecialAttackManager, + private val worldRepo: WorldRepository, + ) : MeleeSpecialAttack { override suspend fun ProtectedAccess.attack( target: Npc, attack: CombatAttack.Melee, @@ -42,11 +48,31 @@ class SaradominBlessedSwordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.lightning(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.blessed_saradomin_sword_special_player") + // Same reference implementation as the base Saradomin sword (Zenyte-based + // Offline_Scape, BLESSED_SARADOMINS_LIGHTNING in SpecialAttack.java) - shares the + // caster glow (1213) and the target's entity-attached lightning (1196, matching the + // base sword exactly) but adds a third effect the base sword doesn't have: a + // ground-location graphic at the target's own tile (`godwars_saradomin_light_attk_spot`, + // 1221, height 0, delay 30) sent via World.sendGraphics to a coord rather than attached + // to the entity - a genuinely richer effect for the blessed upgrade. + spotanim( + spot = "spotanim.dh_sword_update_saradomin_god_special_spotanim", + height = 0, + slot = constants.spotanim_slot_combat, + ) target.spotanim( - spot = "spotanim.saradomin_lightning", - height = 96, + 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( 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 index dc80ec7d9..932ca7c08 100644 --- 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 @@ -42,9 +42,31 @@ class SaradominSwordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.lightning(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.saradomin_sword_special_player") + // Was only playing `saradomin_lightning` (76) on the target - confirmed via a real + // packet capture that's genuinely all this ever sent, no caster effect at all. Found + // a real reference implementation of this exact weapon's special (Zenyte-based + // Offline_Scape, SARADOMINS_LIGHTNING in SpecialAttack.java) with both graphics: + // - Caster: `new Graphics(1213)` passed into the special's own declaration, applied + // automatically to the player by the generic combat framework before the per-weapon + // handler runs (PlayerCombat.java: `player.setGraphics(special.getGraphics())`). + // 1213's real name is `dh_sword_update_saradomin_god_special_spotanim` - purpose- + // built for this exact special, not reused from anywhere else. height/delay default + // to 0 (Graphics(id) alone means Graphics(id, delay=0, height=0)). + // - Target: `new Graphics(1196, 30, 0)` (id, delay, height) - confirms the 1196 id + // already found live, but height 0 (not 96) with a 30-client-cycle delay before it + // plays, timed to the swing's impact point rather than instantly. + // `saradomin_lightning` (76) isn't used by this weapon's real special at all - the + // earlier "confirmed self-applied" evidence was a real but coincidental reuse of that + // id elsewhere (digging, an unrelated NPC), not this weapon. + spotanim( + spot = "spotanim.dh_sword_update_saradomin_god_special_spotanim", + height = 0, + slot = constants.spotanim_slot_combat, + ) target.spotanim( - spot = "spotanim.saradomin_lightning", - height = 96, + spot = "spotanim.godwars_saradomin_magic_attack_spotanim", + height = 0, + delay = 30, slot = constants.spotanim_slot_combat, ) 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 d5cd8bf14..45a4ed1f9 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 @@ -79,12 +79,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 } @@ -93,7 +93,6 @@ 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(DARK_BOW_FIRE_SEQUENCE) @@ -105,13 +104,18 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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) @@ -145,7 +149,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.") @@ -156,7 +166,6 @@ 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(DARK_BOW_FIRE_SEQUENCE) @@ -168,13 +177,18 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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) @@ -208,7 +222,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.") 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 index e8de443ec..c2f5fb4b5 100644 --- 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 @@ -67,17 +67,19 @@ class MagicBowSpecialAttack @Inject constructor(private val ammunition: RangedAm return false } - val launchSpot = - quiverType.paramOrNull(params.proj_launch)?.let { - RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) - } - spotanim(launchSpot, height = 96, slot = constants.spotanim_slot_combat) + // `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, - RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id), + POWERSHOT_GLOW_SPOTANIM, RSCM.getReverseMapping(RSCMType.PROJANIM, projectileType.id), ) @@ -118,6 +120,10 @@ class MagicBowSpecialAttack @Inject constructor(private val ammunition: RangedAm return true } } + + private companion object { + const val POWERSHOT_GLOW_SPOTANIM = "spotanim.sp_attack_glow_arrow_launch" + } } /** 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 index 807e2e0f9..a4b52ffe1 100644 --- 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 @@ -80,19 +80,23 @@ constructor( return false } - val launchSpotanim = - arrowType.paramOrNull(params.proj_launch)?.let { - RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) - } - spotanim(launchSpotanim, height = 96, slot = constants.spotanim_slot_combat) + // 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. + spotanim(SOULSHOT_LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) val projectile = manager.spawnProjectile( this, target, - RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id), + 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. @@ -126,6 +130,9 @@ constructor( 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" } } From 47a433ae76249b9712d917c32d01f57195d4b223 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:05:37 -0400 Subject: [PATCH 08/21] fix(special-attacks): Dragon spear Shove height, Dragon crossbow Annihilate visuals Both confirmed against a reference implementation of these exact specials (Zenyte-based Offline_Scape / Near Reality, SpecialAttack.java). Dragon spear (and Zamorakian spear/hasta) Shove: caster glow height corrected to 96 - this session's earlier broad height=96->0 sweep had it at 0, live-tested as fine at the time, but the reference (Graphics(253, 0, 96)) and a live re-check both confirm 96 is right. Dragon crossbow Annihilate: was falling back to the weapon's plain normal-fire animation (playRangedWeaponFx) plus the ammo's own plain launch/travel colour - the actual cause of the animation reading as missing entirely. Real values: a dedicated fire-and-reload sequence (xbows_human_fire_and_reload), no caster-side spotanim at all (the reference passes a null graphic), a dedicated bolt-travel glow, and a real impact effect on target (firesurge_impact). Co-Authored-By: Claude Sonnet 5 --- .../attacks/melee/DragonHastaSpecialAttack.kt | 5 ++- .../ranged/DragonCrossbowSpecialAttack.kt | 40 ++++++++++--------- 2 files changed, 25 insertions(+), 20 deletions(-) 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 index 28c5553b9..452df8f76 100644 --- 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 @@ -152,11 +152,14 @@ constructor( } // The cache has a purpose-built attacker/target sequence and matching graphics. + // Height confirmed as 96 (not this session's earlier 0) against a reference + // implementation of this exact special (Zenyte-based Offline_Scape, SHOVE in + // SpecialAttack.java: `new Graphics(253, 0, 96)`), and confirmed live. anim("seq.shove") spotanim( spot = "spotanim.sp_attack_shove_spotanim", slot = constants.spotanim_slot_combat, - height = 0, + height = 96, ) // Human sequence 1066 is invalid for non-human NPC skeletons. // NPC targets use their cache-defined defend animation; PvP players 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 index 6e8f950d9..b0df8cc6d 100644 --- 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 @@ -8,7 +8,6 @@ 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.config.refs.params import org.rsmod.api.player.protect.ProtectedAccess import org.rsmod.api.player.quiver @@ -80,21 +79,16 @@ constructor( mes("You are unable to fire your ammunition.") return false } - if (!playRangedWeaponFx(weaponType)) { - manager.stopCombat(this) - mes("The crossbow fails to fire.") - return false - } - - val launchSpotanim = - quiverType.paramOrNull(params.proj_launch)?.let { - RSCM.getReverseMapping(RSCMType.SPOTANIM, it.id) - } - spotanim( - launchSpotanim, - height = 0, - slot = constants.spotanim_slot_combat, - ) + // Was falling back to the weapon's plain normal-fire animation via + // playRangedWeaponFx (whatever attack_anim_stance1 says) plus the ammo's plain + // launch colour - the actual cause of "animation missing entirely". Found this + // exact special's real values in a reference implementation of it (Zenyte-based + // Offline_Scape, ANNIHILATE in SpecialAttack.java): a dedicated fire-and-reload + // sequence, and no caster-side spotanim at all (that engine passes a null graphic + // into the special's own declaration) - the ammo's plain launch colour it was using + // instead shouldn't be there either. + weaponType.paramOrNull(params.attack_sound_stance1)?.let { soundSynth(it) } + anim("seq.xbows_human_fire_and_reload") val affected = if (mapMultiway()) { @@ -118,8 +112,9 @@ constructor( target = primary, attack = attack, ) - val projectileSpotanim = - RSCM.getReverseMapping(RSCMType.SPOTANIM, travelSpotanim.id) + // 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 = @@ -127,7 +122,7 @@ constructor( manager.spawnProjectile( this, target, - projectileSpotanim, + ANNIHILATE_TRAVEL_SPOTANIM, projectileTypeName, ) } @@ -148,6 +143,11 @@ constructor( 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, @@ -168,6 +168,8 @@ constructor( 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" } } From cbae4c9fde88ef2389d24c46267a230471cc7300 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:52:37 -0400 Subject: [PATCH 09/21] fix(special-attacks): sounds pass, Dragon halberd ground-tile fix, Dragon hasta anim swap Sounds added to Cleave, Puncture, Sever, Shatter, and Dragon halberd's Sweep - all pure additions (nothing played before), confirmed against a reference implementation of these exact specials (Zenyte-based Offline_Scape/Near Reality). Their speculative height changes (96->100) were tried and then reverted back to 96 after live testing raised doubt about the whole batch - kept only what's independently solid: Puncture's hit-timing fix (NPC=1 tick, player=same tick), which the real wiki text confirms directly, not just this reference. Dragon halberd's Sweep had a real structural bug, not just a value mismatch: the direction graphic was attached to the caster's own entity instead of sent to a ground tile between caster and target, which is why it rendered at the player's feet facing them instead of sweeping toward the target. Fixed to compute the real target tile (or midpoint for larger targets) and dispatch a location-based graphic there via WorldRepository, at height 96 (previously unset, defaulting to 0) matching the reference. Dragon hasta's Unleash (brut_dragon_spear/Leagues variant) was using a generic placeholder animation that didn't sync correctly live; swapped to Sunspear's own thrust animation, which looks the same and is confirmed working. Magic shortbow's Snapshot: tried matching the reference's single-glow approach but live testing preferred the original per-draw version, so reverted back to it - kept only the newly-found sound. Co-Authored-By: Claude Sonnet 5 --- .../melee/DragonDaggerSpecialAttack.kt | 18 ++++++++-- .../melee/DragonHalberdSpecialAttack.kt | 35 ++++++++++++++++-- .../attacks/melee/DragonHastaSpecialAttack.kt | 7 ++-- .../melee/DragonLongswordSpecialAttack.kt | 5 +++ .../attacks/melee/DragonMaceSpecialAttack.kt | 10 ++++++ .../melee/DragonScimitarSpecialAttack.kt | 10 ++++++ .../ranged/MagicShortbowSpecialAttack.kt | 36 ++++++++++--------- 7 files changed, 98 insertions(+), 23 deletions(-) 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 index ed8fe3178..ed6e6775d 100644 --- 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 @@ -44,7 +44,11 @@ class DragonDaggerSpecialAttack @Inject constructor(private val poison: WeaponPo target: Npc, attack: CombatAttack.Melee, ): Boolean { - puncture(target, attack, secondHitDelay = 2) + // Wiki: "There is a slight delay between the two hits when the special is used on + // NPCs" - confirmed against a reference implementation of this exact special + // (Zenyte-based Offline_Scape/Near Reality, PUNCTURE in SpecialAttack.java) as + // exactly one tick, not the two this file had before. + puncture(target, attack, secondHitDelay = 1) return true } @@ -52,7 +56,9 @@ class DragonDaggerSpecialAttack @Inject constructor(private val poison: WeaponPo target: Player, attack: CombatAttack.Melee, ): Boolean { - puncture(target, attack, secondHitDelay = 1) + // 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 } @@ -62,6 +68,9 @@ class DragonDaggerSpecialAttack @Inject constructor(private val poison: WeaponPo 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, @@ -98,4 +107,9 @@ class DragonDaggerSpecialAttack @Inject constructor(private val poison: WeaponPo 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 index 4c8325ed9..7900f5a3d 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,7 @@ 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 @@ -22,9 +24,10 @@ class DragonHalberdSpecialAttack 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) + val sweep = Sweep(manager, targets, pvp, worldRepo) registerMelee("obj.dragon_halberd", sweep) registerMelee("obj.bh_dragon_halberd_corrupted", sweep) } @@ -33,6 +36,7 @@ constructor( private val manager: SpecialAttackManager, private val targets: AreaMeleeTargetSelector, private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, ) : MeleeSpecialAttack { override suspend fun ProtectedAccess.attack( target: Npc, @@ -49,7 +53,31 @@ constructor( attack: CombatAttack.Melee, ): Boolean { anim("seq.dragon_halberd_special_attack") - spotanim(HalberdSpecialVisuals.forTarget(player.coords, primary.coords)) + // Sound confirmed against the same reference as HalberdSpecialVisuals' direction fix + // (SWEEP_SOUND = synth 2533, unaliased in this cache). + soundSynth(SWEEP_SOUND) + // Was attaching the sweep graphic to the caster's own entity, which is why it + // rendered at your feet facing you instead of out toward the target. The reference + // sends it to a ground tile instead: the target's own tile for a single-tile target, + // otherwise the midpoint between the target's centre and the caster - and at height + // 96 (`Graphics(id, 0, 96)` for all four directions), not the unset default of 0. + 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) { @@ -95,6 +123,9 @@ constructor( 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 index 452df8f76..ddd4e7470 100644 --- 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 @@ -90,9 +90,10 @@ constructor( // This was missing entirely - without a player anim, the special's spotanim fired but // the player's swing didn't sync with it at all, instead just showing whatever the - // normal attack happened to play. `seq.specialattack_unleash` is a generic, named - // "Unleash" animation already present in this cache. - anim("seq.specialattack_unleash") + // normal attack happened to play. The generic `seq.specialattack_unleash` placeholder + // didn't actually play correctly live; Sunspear's own thrust anim looks the same and + // is confirmed working, so reusing it here instead. + anim("seq.human_weapons_sunspear_spec") spotanim( spot = "spotanim.dragon_hasta_spec_spotanim", slot = constants.spotanim_slot_combat, 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 58577c21d..caa837e5e 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 @@ -58,6 +58,11 @@ class DragonLongswordSpecialAttack : SpecialAttackMap { manager.setNextAttackDelay(this, nextAttackDelay) } + // Height and sound confirmed against a reference implementation of this exact + // special (Zenyte-based Offline_Scape/Near Reality, CLEAVE in SpecialAttack.java: + // `new Graphics(248, 0, 100)`, `player.sendSound(CLEAVE_SOUND)` = synth 2529, which + // has a real gameval alias unlike Puncture/Sever/Shatter's own sounds). + soundSynth("synth.cleave") spotanim( spot = "spotanim.sp_attack_cleave_spotanim", slot = constants.spotanim_slot_combat, 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 index 39c8adaa7..777ac24d1 100644 --- 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 @@ -40,6 +40,11 @@ class DragonMaceSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.shatter(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.shatter") + // Height and sound confirmed against a reference implementation of this exact + // special (Zenyte-based Offline_Scape/Near Reality, SHATTER in SpecialAttack.java: + // `new Graphics(251, 0, 100)`, `player.sendSound(SHATTER_SOUND)` = synth 2541, + // unaliased in this cache). + soundSynth(SHATTER_SOUND) spotanim( spot = "spotanim.sp_attack_shatter_spotanim", slot = constants.spotanim_slot_combat, @@ -60,4 +65,9 @@ class DragonMaceSpecialAttack : SpecialAttackMap { 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 index 8105e589f..0e0247e70 100644 --- 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 @@ -42,6 +42,11 @@ class DragonScimitarSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.sever(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.sp_attack_dragon_scimitar") + // Height and sound confirmed against a reference implementation of this exact + // special (Zenyte-based Offline_Scape/Near Reality, SEVER in SpecialAttack.java: + // `new Graphics(347, 0, 100)`, `player.sendSound(SEVER_SOUND)` = synth 2540, + // unaliased in this cache). + soundSynth(SEVER_SOUND) spotanim( spot = "spotanim.sp_attack_dragon_scimitar_trail_spotanim", slot = constants.spotanim_slot_combat, @@ -67,4 +72,9 @@ class DragonScimitarSpecialAttack : SpecialAttackMap { 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/ranged/MagicShortbowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicShortbowSpecialAttack.kt index 0b34fc28f..1404bd464 100644 --- 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 @@ -36,16 +36,16 @@ import org.rsmod.game.type.getOrNull * back, 27 client cycles each (frame delays 2,2,2,2,8,5,2,2,2,2, twice) - a sped-up double of * the normal `human_bow` (426) draw. The second arrow is therefore loosed 27 client cycles after * the first: within the same server tick, not a tick later. - * - `spotanim.sp_attack_snapshot_spotanim` (256) is Snapshot's only *attacker*-side graphic. It - * sits in the original RS2 spec-graphic block (246-258) alongside `sp_attack_puncture`/`cleave`/ - * `shatter`, all of which play on the caster, and its own animation (1075) is a single 21-cycle - * draw glow. It is the player's per-draw launch glow, one per arrow - not a target-hit effect - * (an earlier version put it on the target; live testing confirmed that read as a wrong effect - * on hit, not a launch). - * - Live testing also confirmed the in-flight arrow itself needs to glow, not just the draw - + * - `spotanim.sp_attack_snapshot_spotanim` (256) is Snapshot's launch glow, one per draw. A + * reference implementation of this special (Zenyte-based Offline_Scape/Near Reality) instead + * uses a single shared glow (250, also Powershot's own) applied once - tried matching that + * exactly, but live testing preferred this original per-draw version, so it's staying. + * - Live testing confirmed the in-flight arrow itself needs to glow, not just the draw - * `spotanim.sp_attack_glow_arrow_travel` (249) overrides the ammo's own `proj_travel` for both - * shots. (An earlier version assumed this pair belonged to Powershot/Soulshot by elimination and - * left the ammo's plain colour in flight; that guess was wrong.) + * shots. The same reference confirms this id and timing exactly (`Projectile(249, ...)` for + * both arrows). + * - Sound (`SNAPSHOT_SOUND` = synth 2545, unaliased in this cache) confirmed against the same + * reference. */ class MagicShortbowSpecialAttack @Inject @@ -102,12 +102,13 @@ constructor( 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 the old version showed + // 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( @@ -118,9 +119,9 @@ constructor( ) // 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) and - // the attacker's draw glow (256) in the same spec-graphic id block. Overrides the - // ammo's own plain proj_travel entirely rather than layering on top of it. + // `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) @@ -238,6 +239,12 @@ constructor( 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. @@ -253,9 +260,6 @@ constructor( */ const val FIRST_ARROW_RELEASE_CYCLE = 16 - /** Any slot other than [constants.spotanim_slot_combat], so the two glows coexist. */ - const val SECOND_GLOW_SPOTANIM_SLOT = 0 - const val ARROWS_PER_SNAPSHOT = 2 const val SNAPSHOT_LEVEL_BONUS = 10 const val RANGED_STRENGTH_BASE = 64 From be0cc8ac0bc5886d3a440db640fe5e9bedbdcf47 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:54:46 -0400 Subject: [PATCH 10/21] fix(special-attacks): sound pass on 15 more specials, Dragon claws multi-hit sound fix Added missing special-attack sounds (raw ids, unaliased in this cache's gamevals) to: Powershot, Chainhit, Soulshot, Saradomin's Lightning (x2), SWH Smash, Wild Stab, Spear Wall, Hamstring, Shield Bash, Impale, Eviscerate, Powerstab, Sanctuary/Lumber Up/Rock Knocker, and Voidwaker's Disrupt/Armadyl godsword's smash. Dragon claws' Slice and Dice previously played the wrong sound entirely (2537, actually Dragon dagger/Abyssal dagger's own Puncture sound, carried over by mistake from the reference port). Replaced with the real ids from the item's own wiki "Sound effects" table (4138/4140/4141, one per swing beat, third and fourth sharing a clip), and fixed a same-tick collision bug where firing all four soundSynth calls with no delay meant only the first one ever played client-side - staggered with delay = index * 20. --- .../attacks/boost/StatBoostSpecialAttacks.kt | 19 ++++++++++ .../melee/AbyssalDaggerSpecialAttack.kt | 9 +++++ .../melee/ArmadylGodswordSpecialAttack.kt | 8 +++++ .../melee/Dragon2hSwordSpecialAttack.kt | 7 ++++ .../attacks/melee/DragonClawsSpecialAttack.kt | 36 ++++++++++++++++++- .../attacks/melee/DragonHastaSpecialAttack.kt | 8 ++++- .../attacks/melee/DragonSwordSpecialAttack.kt | 9 +++++ .../attacks/melee/ElderMaulSpecialAttack.kt | 10 ++++++ .../melee/OsmumtenFangSpecialAttack.kt | 9 +++++ .../attacks/melee/RuneClawsSpecialAttack.kt | 11 +++++- .../melee/SaradominSwordSpecialAttack.kt | 8 +++++ .../melee/StatiusWarhammerSpecialAttack.kt | 10 ++++++ .../attacks/melee/VestaSpearSpecialAttack.kt | 7 ++++ .../attacks/melee/VoidwakerSpecialAttack.kt | 7 ++++ .../attacks/ranged/MagicBowSpecialAttack.kt | 7 ++++ .../MorrigansThrowingAxeSpecialAttack.kt | 7 ++++ .../ranged/RuneThrownaxeSpecialAttack.kt | 7 ++++ .../attacks/ranged/SeercullSpecialAttack.kt | 7 ++++ 18 files changed, 183 insertions(+), 3 deletions(-) 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 feddac7e0..6410ef132 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 @@ -64,6 +64,10 @@ class StatBoostSpecialAttacks @Inject constructor(private val worldRepo: WorldRe private fun sanctuary(access: ProtectedAccess): Boolean { access.statBoost("stat.defence", constant = 8, percent = 0) access.anim("seq.sanctuary") + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, SANCTUARY in SpecialAttack.java). Unaliased in this + // cache's gamevals. + access.soundSynth(SANCTUARY_SOUND) return true } @@ -81,6 +85,10 @@ 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) + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, LUMBER_UP in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(LUMBER_UP_SOUND) return true } @@ -167,6 +175,17 @@ class StatBoostSpecialAttacks @Inject constructor(private val worldRepo: WorldRe say("Smashing!") anim(seq) soundArea(worldRepo, coords, "synth.found_gem", radius = 1) + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, ROCK_KNOCKER in SpecialAttack.java). Unaliased in this + // cache's gamevals. + 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/melee/AbyssalDaggerSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerSpecialAttack.kt index 327260501..408c214fb 100644 --- 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 @@ -17,6 +17,11 @@ import org.rsmod.game.entity.Player * 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) @@ -61,6 +66,10 @@ class AbyssalDaggerSpecialAttack : SpecialAttackMap { // 96 (blindly copied from Dragon claws, the same pattern already fixed on several // other weapons this session - all ended up needing 0, not a smaller-but-still- // nonzero value) sat too high. + // Sound confirmed against a reference implementation of this exact special (Zenyte- + // based Offline_Scape/Near Reality, ABYSSAL_PUNCTURE in SpecialAttack.java - same + // sound id as Dragon dagger's own Puncture). Unaliased in this cache's gamevals. + soundSynth(ABYSSAL_PUNCTURE_SOUND) spotanim( spot = "spotanim.abyssal_dagger_special_spotanim", slot = constants.spotanim_slot_combat, 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 index 9cb304331..af17cbde3 100644 --- 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 @@ -52,6 +52,11 @@ class ArmadylGodswordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.smash(target: PathingEntity, attack: CombatAttack.Melee) { anim(sequence) + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, THE_JUDGEMENT in SpecialAttack.java - the same generic + // slash-special sound id reused for several other weapons' own specials there, e.g. + // Saradomin sword's lightning). Unaliased in this cache's gamevals. + soundSynth(THE_JUDGEMENT_SOUND) target.spotanim( spot = graphic, slot = constants.spotanim_slot_combat, @@ -76,5 +81,8 @@ class ArmadylGodswordSpecialAttack : SpecialAttackMap { 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/Dragon2hSwordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/Dragon2hSwordSpecialAttack.kt index 4f0bc1eb5..9eb31b2f5 100644 --- 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 @@ -49,6 +49,10 @@ constructor( attack: CombatAttack.Melee, ): Boolean { anim("seq.dragon_two_handed_sword") + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, POWERSTAB in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(POWERSTAB_SOUND) spotanim("spotanim.dragon_two_handed_sword_blast") val affected = @@ -87,6 +91,9 @@ constructor( 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/DragonClawsSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsSpecialAttack.kt index c5081aaed..351a519fa 100644 --- 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 @@ -70,15 +70,35 @@ class DragonClawsSpecialAttack : SpecialAttackMap { val hits = rollHits(target, attack, maxHit) + // The wiki's own "Sound effects" table for this item names four distinct clips - + // one per swing beat, third and fourth sharing a clip - not the single generic sound + // this previously played (2537, wrong: that's Dragon dagger/Abyssal dagger's own + // Puncture sound, confirmed dead wrong live). Every beat plays regardless of whether + // that swing's roll actually connected, same as `hits` itself always has 4 entries. + // + // All 4 calls land in the same server tick, and `soundSynth`'s `delay` is exactly + // what the packet has for this: without it, only the first of several same-tick + // synth sounds actually plays client-side (confirmed live - the 1/2/3/3 clips were + // firing but silently collapsing into just the first). Staggered using this + // codebase's own existing per-hit-sound delay convention (`StandardPlayerHitProcessor` + // uses 20 for a single hit-reaction sound), one unit of spacing per swing beat. var totalDamage = 0 - for (damage in hits) { + 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 @@ -126,6 +146,20 @@ class DragonClawsSpecialAttack : SpecialAttackMap { rollInclusive = { lo, hi -> random.of(lo, hi) }, rollUpTo = { exclusiveBound -> random.of(maxExclusive = exclusiveBound) }, ) + + private companion object { + // Sourced directly from the Dragon claws wiki page's own "Sound effects" table + // (Name/Description/ID), not the reference port - unaliased in this cache's + // gamevals, no `synth.` name exists for any of them. + const val DRAGONCLAWS_SPECIAL_1_SOUND = 4138 + const val DRAGONCLAWS_SPECIAL_2_SOUND = 4140 + const val DRAGONCLAWS_SPECIAL_3_SOUND = 4141 + + /** No wiki-confirmed exact timing exists for these four beats; matches the spacing + * already used elsewhere in this codebase for a single hit-reaction sound + * (`StandardPlayerHitProcessor`'s `defendSound, delay = 20`). */ + const val HIT_SOUND_SPACING = 20 + } } } 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 index ddd4e7470..3bbe53fdc 100644 --- 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 @@ -155,8 +155,11 @@ constructor( // The cache has a purpose-built attacker/target sequence and matching graphics. // Height confirmed as 96 (not this session's earlier 0) against a reference // implementation of this exact special (Zenyte-based Offline_Scape, SHOVE in - // SpecialAttack.java: `new Graphics(253, 0, 96)`), and confirmed live. + // SpecialAttack.java: `new Graphics(253, 0, 96)`), and confirmed live. Sound + // confirmed against the same reference (SHOVE_SOUND); unaliased in this cache's + // gamevals. anim("seq.shove") + soundSynth(SHOVE_SOUND) spotanim( spot = "spotanim.sp_attack_shove_spotanim", slot = constants.spotanim_slot_combat, @@ -217,6 +220,9 @@ constructor( 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 } } 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 index 88f8d95a5..37024632c 100644 --- 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 @@ -43,6 +43,10 @@ class DragonSwordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.wildStab(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.human_dragon_sword_spec") + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, WILD_STAB in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(WILD_STAB_SOUND) spotanim( spot = "spotanim.dragon_sword_spec_spotanim", slot = constants.spotanim_slot_combat, @@ -68,4 +72,9 @@ class DragonSwordSpecialAttack : SpecialAttackMap { 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/ElderMaulSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/ElderMaulSpecialAttack.kt index 42e3c01d1..cc0f4ace0 100644 --- 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 @@ -49,6 +49,11 @@ class ElderMaulSpecialAttack : SpecialAttackMap { // Pulverize is one cycle slower than the maul's normal six-cycle attack. manager.setNextAttackDelay(this, 7) anim("seq.human_elder_maul_spec") + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, PULVERIZE in SpecialAttack.java: SHIELD_BASH_SOUND - + // just how the reference itself names this constant, not shared with another weapon). + // Unaliased in this cache's gamevals. + soundSynth(SHIELD_BASH_SOUND) spotanim( spot = "spotanim.spotanim_elder_maul_special", // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live @@ -77,6 +82,11 @@ class ElderMaulSpecialAttack : SpecialAttackMap { 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) { 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 index 7c7af1d9c..5fbdfc028 100644 --- 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 @@ -47,6 +47,10 @@ class OsmumtenFangSpecialAttack : SpecialAttackMap { anim("seq.weapon_sword_osmumten03_special") // Same blind height=96-copied-from-Dragon-claws pattern fixed on several other // specials this session - reported too high live, dropped to ground level. + // Sound confirmed against a reference implementation of this exact special + // (Zenyte-based Offline_Scape/Near Reality, EVISCERATE in SpecialAttack.java: + // OSMUMTEN_FANG_SOUND); unaliased in this cache's gamevals. + soundSynth(OSMUMTEN_FANG_SOUND) spotanim( spot = "spotanim.spotanim_weapon_sword_osmumten_special", height = 0, @@ -81,6 +85,11 @@ class OsmumtenFangSpecialAttack : SpecialAttackMap { 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 + } } /** 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 index c0a6cd1eb..9b9de9352 100644 --- 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 @@ -39,10 +39,14 @@ class RuneClawsSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.impale(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.impale") + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, IMPALE in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(IMPALE_SOUND) spotanim( spot = "spotanim.sp_attack_impale_spotanim", slot = constants.spotanim_slot_combat, - height = 0, + height = 100, ) // Rune claws normally attack every four cycles; Impale is a five-cycle attack. @@ -62,4 +66,9 @@ class RuneClawsSpecialAttack : SpecialAttackMap { 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/SaradominSwordSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/SaradominSwordSpecialAttack.kt index 932ca7c08..e0a59dc8d 100644 --- 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 @@ -42,6 +42,10 @@ class SaradominSwordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.lightning(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.saradomin_sword_special_player") + // Same reference as the graphics above - two sounds, both unaliased in this cache's + // gamevals. + soundSynth(SARADOMINS_LIGHTNING_SWORD_SOUND) + soundSynth(SARADOMINS_LIGHTNING_SOUND) // Was only playing `saradomin_lightning` (76) on the target - confirmed via a real // packet capture that's genuinely all this ever sent, no caster effect at all. Found // a real reference implementation of this exact weapon's special (Zenyte-based @@ -114,6 +118,10 @@ class SaradominSwordSpecialAttack : SpecialAttackMap { 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 } } } 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 index 24e858dd3..4f216a7b2 100644 --- 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 @@ -52,6 +52,11 @@ class StatiusWarhammerSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.smash(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.human_blunt_pound") + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, SWH_SMASH in SpecialAttack.java: SMASH_SOUND - shared + // with the unrelated Dragon warhammer's own SMASH, same id). Unaliased in this + // cache's gamevals. + soundSynth(SMASH_SOUND) spotanim( spot = "spotanim.statius_hammer_sa_spotanim", slot = constants.spotanim_slot_combat, @@ -108,4 +113,9 @@ class StatiusWarhammerSpecialAttack : SpecialAttackMap { } } } + + 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/VestaSpearSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/melee/VestaSpearSpecialAttack.kt index f03641d75..882f43d58 100644 --- 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 @@ -66,6 +66,10 @@ constructor( ): Boolean { // These cache-native effects do not have RSCM aliases in this revision. player.anim(RSCM.getReverseMapping(RSCMType.SEQ, VESTA_SPEAR_WALL_ANIMATION)) + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, SPEAR_WALL in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(SPEAR_WALL_SOUND) player.spotanim( spot = RSCM.getReverseMapping(RSCMType.SPOTANIM, VESTA_SPEAR_WALL_SPOTANIM), height = 0, @@ -122,5 +126,8 @@ constructor( 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 index 7ff2a4d9c..4444f6876 100644 --- 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 @@ -46,6 +46,10 @@ class VoidwakerSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.disrupt(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.human_special02_voidwaker") + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, DISRUPT in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(DISRUPT_SOUND) spotanim( spot = "spotanim.fx_voidwaker02_special", slot = constants.spotanim_slot_combat, @@ -84,6 +88,9 @@ class VoidwakerSpecialAttack : SpecialAttackMap { } 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/MagicBowSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowSpecialAttack.kt index c2f5fb4b5..7c408f1a3 100644 --- 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 @@ -66,6 +66,10 @@ class MagicBowSpecialAttack @Inject constructor(private val ammunition: RangedAm mes("The bow fails to fire.") return false } + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, POWERSHOT in SpecialAttack.java). Unaliased in this + // cache's gamevals. + 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 @@ -123,6 +127,9 @@ class MagicBowSpecialAttack @Inject constructor(private val ammunition: RangedAm 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 } } 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 index ba70a53c3..2eed18b69 100644 --- 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 @@ -87,6 +87,10 @@ constructor(private val ammunition: RangedAmmoManager) : SpecialAttackMap { } anim(MORRIGANS_THROWING_AXE_SEQUENCE) + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, HAMSTRING in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(HAMSTRING_SOUND) spotanim( spot = MORRIGANS_THROWING_AXE_LAUNCH_SPOTANIM, // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live @@ -179,6 +183,9 @@ constructor(private val ammunition: RangedAmmoManager) : SpecialAttackMap { 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 } } 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 index ca96683ec..32cecee42 100644 --- 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 @@ -77,6 +77,10 @@ constructor( } anim(CHAINHIT_SEQUENCE) + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, CHAINHIT in SpecialAttack.java: THROWNAXE_SOUND). + // Unaliased in this cache's gamevals. + soundSynth(THROWNAXE_SOUND) spotanim( spot = CHAINHIT_LAUNCH_SPOTANIM, height = 96, @@ -223,6 +227,9 @@ constructor( 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 } 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 index a4b52ffe1..9ff9adcff 100644 --- 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 @@ -87,6 +87,10 @@ constructor( // 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. + // Confirmed against a reference implementation of this exact special (Zenyte-based + // Offline_Scape/Near Reality, SOULSHOT in SpecialAttack.java). Unaliased in this + // cache's gamevals. + soundSynth(SOULSHOT_SOUND) spotanim(SOULSHOT_LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) val projectile = @@ -133,6 +137,9 @@ constructor( 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 } } From e31a0643272d974b7fcfdd7b1533acf8d78941c0 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:02:56 -0400 Subject: [PATCH 11/21] fix(special-attacks): wiki sound sweep + blowpipe/Webweaver scale-loading bug + equip crash hardening Special attacks: added wiki-sourced sounds for Abyssal bludgeon (Penance), Barrelchest anchor (Sunder), Burning claws (Burning Barrage), Emberlight (Weaken), Tonalztics of ralos (Division, charged variant). Updated VISUAL_QA.md to reflect Dragon hasta's animation fix from earlier this session. Fixed "use scales on blowpipe/Webweaver bow" doing nothing: Zulrah's scales are also a Herblore ingredient (Extended antivenom+), and a generic "any herblore ingredient used on anything" catch-all was matching first whenever scales were the item clicked first, silently no-opping since blowpipe/bow aren't potion components - before dispatch ever got to try the reversed click order that would reach the real handler. Excluded scales from that catch-all in FinishedPotionsEvents, same pattern already used there for vial water/pestle/swamp tar. Hardened HeldEquipOp so an unexpected inventory transaction result fails the equip cleanly with a message instead of throwing an uncaught exception that disconnects the player. Added ::wornclear admin command (mirrors the existing ::invclear) to recover a worn slot that ends up in a bad state, since invclear only reaches the inventory bag. --- .../org/rsmod/api/player/worn/HeldEquipOp.kt | 26 ++++++++++++++----- .../content/other/commands/AdminCommands.kt | 6 +++++ content/other/special-attacks/VISUAL_QA.md | 6 +++-- .../melee/AbyssalBludgeonSpecialAttack.kt | 15 +++++++++++ .../melee/BarrelchestAnchorSpecialAttack.kt | 8 ++++++ .../attacks/melee/BrineSabreSpecialAttack.kt | 8 ++++++ .../melee/BurningClawsSpecialAttack.kt | 8 ++++++ .../attacks/melee/DemonbaneSpecialAttacks.kt | 14 +++++++++- .../ranged/TonalzticsOfRalosSpecialAttack.kt | 15 +++++++++++ .../scripts/charge/BlowpipeCharging.kt | 14 +++++++--- .../skills/herblore/FinishedPotionsEvents.kt | 10 +++++++ 11 files changed, 117 insertions(+), 13 deletions(-) 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..9bf7750a4 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,17 @@ 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) { + // A `check()` here used to throw on any unexpected transaction result (e.g. a + // corrupted item elsewhere in the inventory tripping VarObjIncorrectlyHasCert + // during this transaction's own stack-merge scan) - an uncaught exception + // mid-input-cycle disconnects the player instead of just failing the equip. + // Fail cleanly and log server-side 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 +92,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) 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 bcc95509f..f484d6503 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 @@ -140,6 +140,7 @@ constructor( onCommand("item", "Spawn obj into inv", ::invAdd) onCommand("invclear", "Remove all objs from inv", ::invClear) + onCommand("wornclear", "Remove all worn objs", ::wornClear) onCommand("varp", "Set varp value", ::setVarp) { invalidArgs = "Use as ::varp debugNameOrId value (ex: option_run 1)" } @@ -593,6 +594,11 @@ constructor( private fun invClear(cheat: Cheat) = with(cheat) { player.invClear(player.inv) } + private fun wornClear(cheat: Cheat) = with(cheat) { + player.invClear(player.worn) + player.rebuildAppearance() + } + private fun setVarp(cheat: Cheat) = with(cheat) { val typeId = "varp.${args[0]}".asRSCM() diff --git a/content/other/special-attacks/VISUAL_QA.md b/content/other/special-attacks/VISUAL_QA.md index bb96e3729..40775c7d6 100644 --- a/content/other/special-attacks/VISUAL_QA.md +++ b/content/other/special-attacks/VISUAL_QA.md @@ -11,7 +11,7 @@ something's still wrong so we don't lose track between sessions. - [x] Barrelchest anchor - [x] Ancient mace - [x] Dragon claws -- [x] Dragon hasta (height only - still missing an animation, see below) +- [x] Dragon hasta (height + animation - Unleash now uses Sunspear's thrust anim, confirmed live) - [x] Dual macuahuitl - [x] Rune claws - [x] Statius warhammer @@ -124,7 +124,9 @@ something's still wrong so we don't lose track between sessions. custom addition, not from the real game, which is why it never showed up in any external search. Animation itself confirmed fine as just the plain normal-attack draw, same as Magic longbow's Powershot. -- [ ] Dragon hasta - missing an animation (something like Sunspear's thrust) +- [x] Dragon hasta - the generic `seq.specialattack_unleash` placeholder didn't sync correctly + live; swapped Unleash to Sunspear's own thrust animation (`seq.human_weapons_sunspear_spec`) + instead - looks the same, confirmed working live. - [x] Dogsword - `statHeal` crash fixed (`coerceIn(current, base)` could have `current > base` - already-boosted stat - and threw; now `coerceIn(current, maxOf(current, base))`, a shared fix in `PlayerStatExtensions.kt` that covers every heal-based special, not just Dogsword) 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 index f0fd5eda0..9fa09942a 100644 --- 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 @@ -41,6 +41,11 @@ class AbyssalBludgeonSpecialAttack : SpecialAttackMap { attack: CombatAttack.Melee, ) { anim("seq.abyssal_bludgeon_special_attack") + // Sourced from this item's own wiki "Sound effects" table: the swing itself reuses + // Granite maul's "quicksmash" sound, and the miasma pool gets its own "smokepuff" + // cue. Both 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, @@ -65,6 +70,16 @@ class AbyssalBludgeonSpecialAttack : SpecialAttackMap { 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 + + /** Matches this codebase's own same-tick-collision spacing convention (see Dragon + * claws). Without it, the swing sound wins and the pool cue never plays. */ + const val MIASMA_POOL_SOUND_DELAY = 20 + } } /** 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 index 821e136ad..fb52d8fb5 100644 --- 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 @@ -51,6 +51,9 @@ class BarrelchestAnchorSpecialAttack : SpecialAttackMap { attack: CombatAttack.Melee, ) { anim("seq.brain_player_anchor_special_attack") + // Sourced from this item's own wiki "Sound effects" table ("anchor_sunder"). + // Unaliased in this cache's gamevals. + soundSynth(SUNDER_SOUND) spotanim( spot = "spotanim.brain_anchor_special_attack_spot", height = 0, @@ -75,6 +78,11 @@ class BarrelchestAnchorSpecialAttack : SpecialAttackMap { 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) { 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 index f81f3866a..b4259b4f4 100644 --- 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 @@ -47,6 +47,9 @@ class BrineSabreSpecialAttack : SpecialAttackMap { } anim("seq.olaf2_brine_sabre_special") + // Sourced from this item's own wiki "Sound effects" table + // ("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, @@ -74,6 +77,11 @@ class BrineSabreSpecialAttack : SpecialAttackMap { 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( 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 index efbf209c1..d580177b2 100644 --- 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 @@ -54,6 +54,9 @@ constructor( attack: CombatAttack.Melee, ) { anim("seq.human_weapon_burning_claws_02_spec") + // Sourced from this item's own wiki "Sound effects" table + // ("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, @@ -102,6 +105,11 @@ constructor( 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 + } } /** 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 index 5572da9ea..da57cc87c 100644 --- 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 @@ -27,13 +27,16 @@ class DemonbaneSpecialAttacks : SpecialAttackMap { registerMelee("obj.arclight", arclight) registerMelee("obj.arclight_inactive", arclight) - val emberlight = Weaken(manager, demonDrainPercent = 15) + // 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, @@ -57,6 +60,10 @@ class DemonbaneSpecialAttacks : SpecialAttackMap { ) { anim("seq.dark_spec_player") spotanim("spotanim.dark_spec_spot") + // Staggered to avoid the same-tick collision where only the first of several + // same-tick synth sounds plays client-side (see Dragon claws). Unaliased in this + // cache's gamevals. + sounds.forEachIndexed { index, sound -> soundSynth(sound, delay = index * SOUND_SPACING) } val successful = manager.rollMeleeAccuracy( source = this, @@ -115,5 +122,10 @@ class DemonbaneSpecialAttacks : SpecialAttackMap { 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/ranged/TonalzticsOfRalosSpecialAttack.kt b/content/other/special-attacks/src/main/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialAttack.kt index d37a7ba2f..be0d31b38 100644 --- 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 @@ -52,6 +52,14 @@ class TonalzticsOfRalosSpecialAttack @Inject constructor() : SpecialAttackMap { ): Boolean { getInvObj(attack.weapon) anim(if (hitCount == CHARGED_HIT_COUNT) CHARGED_SPECIAL_ANIM else UNCHARGED_SPECIAL_ANIM) + // The charged variant's spinning throw is the only one with dedicated special-attack + // sounds on this item's own wiki "Sound effects" table (throw + spin); the uncharged + // single throw has no separate special entry there. Unaliased in this cache's + // gamevals. + 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, @@ -177,6 +185,13 @@ class TonalzticsOfRalosSpecialAttack @Inject constructor() : SpecialAttackMap { 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 + + /** Matches this codebase's own same-tick-collision spacing convention (see Dragon + * claws) so the spin cue doesn't collide with the throw cue. */ + const val CHARGED_SPIN_SOUND_DELAY = 20 } } 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 index a4e14d669..ce422bb1b 100644 --- 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 @@ -34,10 +34,16 @@ import org.rsmod.plugin.scripts.ScriptContext * slot `TumekensShadowCharging` already uses for its own worn-Check), which is a genuinely free * slot in this engine and doesn't touch Remove. * - * The registration order for `onOpHeldU` here is (pipe, ammo), but live testing found only "use - * blowpipe on scales" actually triggers the interaction - "use scales on blowpipe" does nothing, - * even though this framework's own `onOpHeldU` doc claims click order shouldn't matter. Not chased - * further since the working direction is confirmed functional; may be worth revisiting. + * "Use scales on blowpipe" used to do nothing (only "blowpipe on scales" worked), despite + * `onOpHeldU`'s own dispatch correctly trying both click orders. Root cause wasn't this + * registration at all: Zulrah's scales are also a Herblore ingredient (Extended antivenom+, + * `obj.antivenom+3` + scales), and `FinishedPotionsEvents` registers a catch-all "any herblore + * ingredient used on anything" handler for every such item. Since that catch-all matched first + * whenever scales were the item clicked first, and it silently no-ops when the second item isn't a + * matching potion, dispatch never got to try the reversed order that would've reached this file's + * own (pipe, scale) handler. Fixed by excluding `obj.snakeboss_scale` from that catch-all (see + * `FinishedPotionsEvents.heldUExclude`) - the Extended antivenom+ recipe still works via + * `obj.antivenom+3`'s own catch-all. */ class BlowpipeCharging @Inject constructor(private val objRepo: ObjRepository) : PluginScript() { override fun ScriptContext.startup() { 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..eb639f979 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,16 @@ 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 is a generic + // "any herblore ingredient used on anything" handler that silently no-ops when the + // second item isn't a matching potion component (see handleFinishedPotionMix's early + // `candidates.isEmpty()` return) - and since it's the first thing checked when scales + // are the item you click first, it swallowed "scales on blowpipe"/"scales on Webweaver + // bow" before dispatch ever got to try the reversed order. Its only real recipe + // (Extended antivenom+) still works via the other ingredient's own catch-all + // (obj.antivenom+3 used on scales), same as how vial_water/pestle/swamp tar are + // excluded here in favour of their own dedicated handlers. + add("obj.snakeboss_scale") addAll(HerbloreDefinitions.herbItemNames) HerbloreDefinitions.barbarianMixes.forEach { mix -> add(mix.twoDosePotion.internalName) From 5447dacbb42c014d82a95eb8f255517545057582 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:21:55 -0400 Subject: [PATCH 12/21] feat(special-weapons): implement Craw's bow, fix ObjChargeManager charge/uncharge edge case Craw's bow was entirely unimplemented (no normal attack, no charging) - added CrawsBowCharging (activate/top up/check/uncharge) and CrawsBowWeapons (normal ranged attack generating its own ammo, +50% accuracy/damage vs NPCs in the Wilderness while charged), mirroring the Webweaver bow it upgrades into - same activation cost (1000 ether), max stored ammo (16000), and Wilderness multiplier per the wiki. Also added the charged_variant/uncharged_variant params in items.toml that were missing from the cache entirely, required for ObjChargeManager to transform the item between its charged/uncharged forms. Fixed a real edge case in ObjChargeManager.addCharges: it used "0 stored charges" as a proxy for "this obj is the uncharged variant", but an obj can legitimately be the already-charged item with 0 stored charges (e.g. activated with exactly the minimum ether, none left over as ammo) - topping that up threw instead of adding to the same obj. Now checks the obj's own charged_variant param directly instead, matching the pattern already used by reduceWornCharges/removeAllCharges in the same file. --- .data/raw-cache/server/items.toml | 7 + .../rsmod/api/obj/charges/ObjChargeManager.kt | 19 +-- .../special/weapons/RangedWeaponsModule.kt | 2 + .../special/weapons/ranged/CrawsBowWeapons.kt | 130 ++++++++++++++++ .../scripts/charge/CrawsBowCharging.kt | 146 ++++++++++++++++++ 5 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/ranged/CrawsBowWeapons.kt create mode 100644 content/other/special-weapons/src/main/kotlin/org/rsmod/content/other/special/weapons/scripts/charge/CrawsBowCharging.kt diff --git a/.data/raw-cache/server/items.toml b/.data/raw-cache/server/items.toml index d037e6c6c..3614d0337 100644 --- a/.data/raw-cache/server/items.toml +++ b/.data/raw-cache/server/items.toml @@ -19022,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]] @@ -19042,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]] 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 4dc037e8c..fca50d4f6 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 @@ -61,17 +61,14 @@ 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 still the uncharged variant is determined by its own item + // definition (does it define a `charged_variant` param at all), not by its current charge + // count. `curr == 0` used to gate this instead: that broke a legitimate case where an + // obj is already the *charged* item but happens to hold 0 stored charges right now (e.g. + // the Webweaver bow activated with exactly its minimum ether cost, none left over as + // ammo) - topping that up threw here instead of just adding to the same obj. + 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) } 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 4a7aa2cca..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 @@ -2,6 +2,7 @@ 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 @@ -9,6 +10,7 @@ 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/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/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 + } +} From cd57ca4a5c6f0cb9eb8079ec5c4d3bb6fdb065ba Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:41:10 -0400 Subject: [PATCH 13/21] chore(special-attacks): PR scoping cleanup - trim comments, drop debug commands and progress docs Scoped this branch down to just special attacks/weapons and their directly associated changes, ahead of opening the PR: - Removed every admin command added on this branch that isn't in main (::me, ::charge, ::wornclear) and reverted ::spot/::item's cosmetic text changes to match main exactly - these were testing convenience tooling, not shipped content. - Deleted PROGRESS.md and VISUAL_QA.md - internal dev-tracking docs, not game content. - Trimmed AI-narrative-style comments across special-attacks, special-weapons, and the supporting herblore/objtx/equip files down to short factual notes - removed the "confirmed against a reference implementation", "this session", and "needs your eyes to confirm" style commentary that shouldn't ship in a PR. No behavioral changes - compiled and boot-tested clean. --- .../rsmod/api/obj/charges/ObjChargeManager.kt | 9 +- .../org/rsmod/api/player/worn/HeldEquipOp.kt | 6 +- .../content/other/commands/AdminCommands.kt | 79 +- content/other/special-attacks/PROGRESS.md | 1176 ----------------- content/other/special-attacks/VISUAL_QA.md | 152 --- .../special/attacks/SpecialAttackModule.kt | 2 +- .../attacks/boost/StatBoostSpecialAttacks.kt | 9 - .../melee/AbyssalBludgeonSpecialAttack.kt | 7 +- .../melee/AbyssalDaggerSpecialAttack.kt | 6 - .../attacks/melee/ArkanBladeSpecialAttack.kt | 3 - .../melee/ArmadylGodswordSpecialAttack.kt | 6 +- .../melee/BarrelchestAnchorSpecialAttack.kt | 2 - .../attacks/melee/BrineSabreSpecialAttack.kt | 3 +- .../melee/BurningClawsSpecialAttack.kt | 6 +- .../attacks/melee/DemonbaneSpecialAttacks.kt | 3 - .../melee/Dragon2hSwordSpecialAttack.kt | 3 - .../attacks/melee/DragonClawsSpecialAttack.kt | 24 +- .../melee/DragonDaggerSpecialAttack.kt | 4 +- .../melee/DragonHalberdSpecialAttack.kt | 9 +- .../attacks/melee/DragonHastaSpecialAttack.kt | 16 +- .../melee/DragonLongswordSpecialAttack.kt | 4 - .../attacks/melee/DragonMaceSpecialAttack.kt | 4 - .../melee/DragonScimitarSpecialAttack.kt | 4 - .../attacks/melee/DragonSwordSpecialAttack.kt | 3 - .../attacks/melee/ElderMaulSpecialAttack.kt | 7 - .../melee/ImpactMeleeSpecialAttacks.kt | 12 - .../melee/OsmumtenFangSpecialAttack.kt | 5 - .../attacks/melee/RuneClawsSpecialAttack.kt | 3 - .../SaradominBlessedSwordSpecialAttack.kt | 9 +- .../melee/SaradominSwordSpecialAttack.kt | 20 +- .../melee/SoulreaperAxeSpecialAttack.kt | 3 - .../melee/StatiusWarhammerSpecialAttack.kt | 4 - .../attacks/melee/VestaSpearSpecialAttack.kt | 3 - .../attacks/melee/VoidwakerSpecialAttack.kt | 3 - .../ranged/DragonCrossbowSpecialAttack.kt | 9 +- .../attacks/ranged/MagicBowSpecialAttack.kt | 3 - .../ranged/MagicShortbowSpecialAttack.kt | 24 +- .../MorrigansThrowingAxeSpecialAttack.kt | 6 - .../ranged/RuneThrownaxeSpecialAttack.kt | 3 - .../attacks/ranged/SeercullSpecialAttack.kt | 3 - .../ranged/TonalzticsOfRalosSpecialAttack.kt | 7 +- .../weapons/ranged/WebweaverBowWeapons.kt | 5 - .../scripts/charge/BlowpipeCharging.kt | 12 +- .../skills/herblore/FinishedPotionsEvents.kt | 13 +- 44 files changed, 43 insertions(+), 1651 deletions(-) delete mode 100644 content/other/special-attacks/PROGRESS.md delete mode 100644 content/other/special-attacks/VISUAL_QA.md 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 fca50d4f6..95663f0ae 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 @@ -61,12 +61,9 @@ public class ObjChargeManager { val updatedVar = obj.vars.withBits(varobj.bits, total) val added = total - curr - // Whether this obj is still the uncharged variant is determined by its own item - // definition (does it define a `charged_variant` param at all), not by its current charge - // count. `curr == 0` used to gate this instead: that broke a legitimate case where an - // obj is already the *charged* item but happens to hold 0 stored charges right now (e.g. - // the Webweaver bow activated with exactly its minimum ether cost, none left over as - // ammo) - topping that up threw here instead of just adding to the same obj. + // 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) 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 9bf7750a4..587ecf316 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 @@ -75,11 +75,7 @@ public class HeldEquipOp @Inject constructor(private val eventBus: EventBus) { val equipTransaction = transaction[0] if (equipTransaction.isErr()) { if (equipTransaction !is TransactionResult.NotEnoughSpace) { - // A `check()` here used to throw on any unexpected transaction result (e.g. a - // corrupted item elsewhere in the inventory tripping VarObjIncorrectlyHasCert - // during this transaction's own stack-merge scan) - an uncaught exception - // mid-input-cycle disconnects the player instead of just failing the equip. - // Fail cleanly and log server-side instead. + // An uncaught exception here disconnects the player - fail cleanly instead. logger.error { "Unexpected equip transaction error (expected NotEnoughSpace): " + "found=$equipTransaction, obj=$objType" 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 f484d6503..b894ecf65 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 @@ -6,7 +6,6 @@ import dev.openrune.rscm.RSCM import dev.openrune.rscm.RSCM.asRSCM import dev.openrune.rscm.RSCMType import dev.openrune.types.NpcMode -import dev.openrune.util.Wearpos import jakarta.inject.Inject import kotlin.math.max import kotlin.math.min @@ -23,10 +22,8 @@ import org.rsmod.api.invtx.invClear import org.rsmod.api.mechanics.toxins.impl.PlayerDisease import org.rsmod.api.mechanics.toxins.impl.PlayerPoison import org.rsmod.api.mechanics.toxins.impl.PlayerVenom -import org.rsmod.api.obj.charges.ObjChargeManager import org.rsmod.api.player.cheat.adminGodMode import org.rsmod.api.player.cheat.adminMaxHit -import org.rsmod.api.player.righthand import org.rsmod.api.player.ironman.PlayerGamemode import org.rsmod.api.player.ironman.setGamemode import org.rsmod.api.player.debug.componentClickDebug @@ -47,7 +44,6 @@ import org.rsmod.api.player.vars.resyncVar import org.rsmod.api.registry.region.RegionRegistry import org.rsmod.api.repo.loc.LocRepository import org.rsmod.api.repo.npc.NpcRepository -import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.utils.format.formatAmount import org.rsmod.api.utils.system.SafeServiceExit import org.rsmod.game.GameUpdate @@ -55,7 +51,6 @@ import org.rsmod.game.cheat.Cheat import org.rsmod.game.entity.Npc import org.rsmod.game.entity.Player import org.rsmod.game.entity.PlayerList -import org.rsmod.game.entity.util.PathingEntityCommon import org.rsmod.game.loc.LocAngle import org.rsmod.game.loc.LocEntity import org.rsmod.game.loc.LocInfo @@ -84,7 +79,6 @@ constructor( private val regions: RegionRegistry, private val deathKillHooks: Set, private val instanceRegistry: BossInstanceRegistry, - private val objCharges: ObjChargeManager, ) : PluginScript() { private val logger = InlineLogger() @@ -95,7 +89,6 @@ constructor( override fun ScriptContext.startup() { onCommand("master", "Max out all stats", ::master) onCommand("reset", "Reset all stats", ::reset) - onCommand("me", "Fill special attack energy to max", ::maxSpecialEnergy) onCommand("mypos", "Get current coordinates", ::mypos) onCommand("tele", "Teleport to coordgrid", ::tele) { invalidArgs = "Usage: ::tele mx mz [level](e.g. ::tele 3200 3200 0)" @@ -111,7 +104,7 @@ constructor( } onCommand("anim", "Play animation", ::anim) onCommand("spot", "Play spotanim", ::spotanim) { - invalidArgs = "Use as ::spot spotanimDebugNameOrId [height] (ex: fx_emote_party01_active or 157)" + invalidArgs = "Use as ::spot spotanimDebugNameOrId (ex: fx_emote_party01_active)" } onCommand("synth", "Play synth sound", ::synth) { invalidArgs = "Use as ::synth idOrName (ex: ::synth 3600 or ::synth pillory_wrong)" @@ -137,10 +130,9 @@ constructor( } onCommand("invadd", "Spawn obj into inv", ::invAdd) - onCommand("item", "Spawn obj into inv", ::invAdd) + onCommand("item", "Spawn obj into inv (ex: ::item 995 100 or ::item coins 100)", ::invAdd) onCommand("invclear", "Remove all objs from inv", ::invClear) - onCommand("wornclear", "Remove all worn objs", ::wornClear) onCommand("varp", "Set varp value", ::setVarp) { invalidArgs = "Use as ::varp debugNameOrId value (ex: option_run 1)" } @@ -165,14 +157,6 @@ constructor( } onCommand("venom", "Test player venom (escalating damage timer)", ::venomTest) onCommand("venomclear", "Clears Venom", ::venomClear) - onCommand( - "charge", - "Add charges to the currently wielded weapon (for testing - some raw materials, " + - "e.g. revenant ether, don't exist in this cache at all)", - ::chargeTest, - ) { - invalidArgs = "Use as ::charge varobjName amount (ex: ::charge charges_16383 5000)" - } onCommand("disease", "Test disease (drain per tick, default 3)", ::diseaseTest) { invalidArgs = "Use as ::disease [drainPerTick] (e.g. ::disease 5)" } @@ -283,32 +267,6 @@ constructor( private fun venomClear(cheat: Cheat) = with(cheat) { PlayerVenom.clear(player) } - private fun chargeTest(cheat: Cheat) = - with(cheat) { - if (player.righthand == null) { - player.mes("You aren't wielding anything.") - return@with - } - val varobjName = "varobj.${args[0]}" - val amount = args[1].toIntOrNull() - if (amount == null || amount <= 0) { - player.mes("Amount must be a positive number.") - return@with - } - // `max` is just a generous debug-tool ceiling, not the item's real cap - if it's too - // high for this specific varobj's actual bit width, the thrown message will report - // the real valid range to retry with. - val result = - objCharges.addCharges( - inventory = player.worn, - slot = Wearpos.RightHand.slot, - add = amount, - internal = varobjName, - max = Short.MAX_VALUE.toInt(), - ) - player.mes("Charge result: $result") - } - private fun diseaseTest(cheat: Cheat) = with(cheat) { val drain = args.getOrNull(0)?.toIntOrNull() ?: 3 @@ -332,18 +290,6 @@ constructor( private fun reset(cheat: Cheat) = with(cheat) { player.setStatLevels(level = 1) } - private fun maxSpecialEnergy(cheat: Cheat) = - with(cheat) { - val type = ServerCacheManager.getVarp("varp.sa_energy".asRSCM()) - if (type == null) { - player.mes("Could not find varp 'sa_energy'.") - return - } - player.vars.backing[type.id] = SpecialAttackEnergy.MAX_ENERGY - player.resyncVar(type) - player.mes("Special attack energy filled to max.") - } - private fun mypos(cheat: Cheat) = with(cheat) { player.mes("${player.coords}:") @@ -441,22 +387,6 @@ constructor( private fun spotanim(cheat: Cheat) = with(cheat) { - val arg = args.getOrNull(0) - if (arg == null) { - player.mes("Use as ::spot spotanimDebugNameOrId [height] (ex: fx_emote_party01_active or 157)") - return - } - // Raw numeric id, bypassing RSCM name resolution entirely - for trying out unnamed/ - // unaliased spotanims still sitting in the raw cache without a debug name yet. - val rawId = arg.toIntOrNull() - if (rawId != null) { - val height = min(args.getOrNull(1)?.toIntOrNull() ?: 0, Short.MAX_VALUE.toInt()) - PathingEntityCommon.spotanim(player, rawId, delay = 0, height = height, slot = 0) - player.mes("Spotanim: $rawId (height=$height)") - logger.debug { "Spotanim: $rawId" } - return - } - val (typeName, heightArg) = args.asTypeNameAndNumber(defaultNumber = 0) val typeId = "spotanim.${typeName}".asRSCM() if (typeId == -1) { @@ -594,11 +524,6 @@ constructor( private fun invClear(cheat: Cheat) = with(cheat) { player.invClear(player.inv) } - private fun wornClear(cheat: Cheat) = with(cheat) { - player.invClear(player.worn) - player.rebuildAppearance() - } - private fun setVarp(cheat: Cheat) = with(cheat) { val typeId = "varp.${args[0]}".asRSCM() diff --git a/content/other/special-attacks/PROGRESS.md b/content/other/special-attacks/PROGRESS.md deleted file mode 100644 index 74c4674a1..000000000 --- a/content/other/special-attacks/PROGRESS.md +++ /dev/null @@ -1,1176 +0,0 @@ -# Special attack verification progress - -Tier A = pure damage/accuracy specials, no engine dependency. Tier B = applies an effect to -something/someone else - originally thought to need a separate, risky engine-diff patch -(`HitImpactHandler` touching core `Npc`/`Player`/`PathingEntity` classes). That premise turned out -to be wrong for most of Tier B - see "Tier B breakthrough" below. - -**Build status: 73 of 73 total weapons compile and run - the source set is now complete.** Since -the last count (70): Bone dagger, Dorgeshuun crossbow, and Vampyre flail (Ivandis/Blisterwood/ -Hallowed) - see "Last three weapons unblocked" below. Nothing remains in `disabled-tier-b/`. - -`compileKotlin`, `compileTestKotlin`, and `test` all exit 0 for everything currently in the source -set. Test files: written for the weapons with genuine custom math (see "Test files" section below) -- the rest (mostly flat accuracy/damage multipliers) don't have much to test beyond "is this -constant right," not yet done. - -## Tier B breakthrough: most of it never needed the engine-diff patch at all - -You asked "if you can do all 47, let's go" - traced every `HitImpactHandler` usage across all 47 -Tier B files and found the real split was **22 easy** (just needed a mechanical rewrite) vs **26 -hard** (need real new subsystems). Two things made the "easy" 22 possible without ever touching -`Npc.kt`/`Player.kt`/`PathingEntity.kt`: - -1. **Real OSRS doesn't clamp damage after the roll** (established earlier tonight). Every - `HitImpactHandler` used only to gate or scale an effect off "how much damage this hit dealt" can - just use the already-known, pre-clamp `damage` value computed a few lines earlier, synchronously - - no callback needed. This covered the large majority of cases (Abyssal whip's energy drain, - Ancient mace/Dragon scimitar/Barrelchest anchor/Elder maul/Statius warhammer/Dogsword's stat - drains, Saradomin sword/Voidwaker's bonus magic xp, the Armadyl-enchanted-bolt effect family - shared by Armadyl crossbow and Zaryte crossbow, Seercull's magic drain, Morrigan's javelin's - bleed, Tonalztics of Ralos's defence drain, Nightmare staff's prayer restore). -2. **For the one genuinely delayed case** (Ancient godsword's mark -> 8-tick-later heal), the - damage was already a fixed, known-in-advance value (not a live roll) - so instead of a callback, - the world-queue mechanism already used elsewhere in that same file just re-schedules the heal - for the same delay the hit itself uses. - -Three real (small) pieces of shared infrastructure came out of this, all outside the -`HitImpactHandler`/engine-diff family: -- **`BypassProtectionPrayerPlayerHitModifier`** (`api/player/hit/modifier/`) - Ancient mace and - Dragon sword's Wild Stab both ignore Protect from Melee entirely; added as a sibling to the - existing `StandardPlayerHitModifier`, and threaded an optional `modifier` param through - `PlayerAttackManager.queueMeleeHit`/`SpecialAttackManager.queueMeleeHit` (default preserves every - other caller's behavior). -- **`PowerOfDeathMeleeProtection`** (`api/player/hit/modifier/`) - Staff of the Dead's Power of - Death (halve incoming melee damage for 60s while the staff stays equipped). Wired directly into - `StandardPlayerHitModifier` since it must apply to *all* incoming melee, not just special-attack - hits. -- **`ProtectionPrayerLockout`** + `Player.disableProtectionPrayers()` (`api/player/prayer/`) - - Dragon scimitar's Sever (PvP): disables active protection prayers and blocks re-activating them - for 8 ticks. Required one small hook in `PrayerTabScript.enablePrayer` (the prayer tab's own - activation entry point) to check the lockout before allowing a protection prayer back on. -- **`defenceMultiplier` threaded through the melee accuracy formula chain** (`PvNMeleeAccuracy`, - `PvPMeleeAccuracy`, `AccuracyFormulae`, `PlayerAttackManager`, `SpecialAttackManager`) - Vesta's - longsword's Feint rolls against 25% of the target's defence rather than boosting its own attack - roll (confirmed these aren't mathematically interchangeable - the real hit-chance formula isn't - symmetric). Default `1.0` preserves every other caller. - -All confirmed via wiki before writing (Dragon scimitar's exact "8 ticks (4.8s)" lockout, Staff of -the Dead's "one minute... lost immediately if the staff is unequipped", Vesta's longsword's -"rolled against 25% of the opponent's defence"). - -### Last three weapons unblocked (resolved - Bone dagger, Dorgeshuun crossbow, Vampyre flail) - -Revisited both previously-deferred gaps and found each was smaller than the earlier note assumed -once actually traced end to end, rather than estimated from the surface. - -**`lastDamagingPlayerUuid` (Bone dagger, Dorgeshuun crossbow)**: still a real core-engine touch, but -turned out to have an existing choke point to hang off rather than needing brand new plumbing. -`PathingEntity.recordDamage(source: Player, damage: Int)` already exists and is already called by -the *entire* shared hit-application pipeline for both NPC and player targets (`StandardNpcHitProcessor` -directly, `StandardPlayerHitProcessor` via `recordHitDamage` -> `Hit.recordDamageOn`) - it's the -same function that already powers `heroPoints`/`damageContributions` for loot/XP-sharing on group -kills, so it was already guaranteed to fire on every positive-damage hit from any weapon, special or -not. Added one field, `PathingEntity.lastDamagingPlayerUuid: Long?`, and one line inside that -existing function (`lastDamagingPlayerUuid = source.uuid`) - zero new call sites, zero behavior -change for anything else that reads `heroPoints`/`damageContributions`. - -**Vampyre flail's Retainer**: all three "smaller pieces" the earlier note wanted verified -(`clearQueue`, `clearInteraction`, `npcChangeType(npc, into, duration)`) already existed with the -exact signatures the disabled file called - nothing to add there. The one apparently-missing piece, -`Npc.retainerUntil`, turned out to be dead state: it was only ever *written*, never read anywhere, -and `npcChangeType`'s own `duration` parameter already auto-reverts the NPC back to its original -type after the given cycle count - so the "trapped for 30 seconds" behavior was already fully -handled by the engine primitive, not by the field. Removed the two `target.retainerUntil = ...` -lines rather than inventing an unused field. - -Both files also still referenced the pre-"Tier B breakthrough" `HitImpactHandler`/`impactHandler` -callback API (removed everywhere else in this project already, see above) - modernized both to the -same synchronous pre-clamp-`damage` pattern used by every other converted Tier B weapon. Also -carried the recurring `height = 96` target-hit-spotanim bug fix (see Osmumten fang/AGS above) into -all three files' target-facing impact spotanims while in there. - -`obj.hallowed_flail` had no `items.toml` block at all (unlike its two siblings, `ivandis_flail`/ -`blisterwood_flail`, which both already had full animation data) - added one mirroring the other -two exactly, since all three flail tiers share the identical `seq.ivandis_flail_*` animation set. - -All three moved out of `disabled-tier-b/` into the live source set and registered in -`SpecialAttackModule.kt`. `BoneDaggerBackstabTest.kt` (pure-logic test for the "was I the target's -last damager" check, already written and waiting) moved with it and passes. Full project -`compileKotlin`/`test` passes. Cache rebuilt (the `hallowed_flail` toml addition needed it). Server -restarted clean, zero errors in the boot log - including zero `SpecialAttackRegistry` collisions, -worth checking explicitly given the Seercull/`daganoth_cave_magic_shortbow` id-collision crash -found earlier this same pass. - -### BlowpipeAmmo built (resolved - Toxic blowpipe, Rosewood blowpipe converted) - -Both special attack files (`ToxicBlowpipeSpecialAttack.kt`, `RosewoodBlowpipeSpecialAttack.kt`) -were already fully written, sitting in `disabled-tier-b/` and referencing a -`org.rsmod.api.player.ranged.BlowpipeAmmo` API that simply didn't exist in this repo yet. Checked -torka's Downloads contribution first (per the established habit this session) and found the -*complete* class already written there -(`OpenRune-Special-Attack-API-Engine-Diff/.../api/player/ranged/BlowpipeAmmo.kt`) - copied it in -directly and it compiled clean on the first try against this repo's current API surface. Real -mechanism: both blowpipe families pack their dart type/count (and, toxic only, scale count) into -the SAME per-item `vars` bitfield `ObjChargeManager` already uses for single-counter charge -weapons - `varobj.snakeboss_blowpipe_darttype`/`_dartcount`/`_flakes`, three independent bit -ranges on the one packed value, all real, pre-existing cache varobjs (confirmed via -`.data/gamevals/varobj.rscm`). `getBits`/`withBits` (same utils `ObjChargeManager` uses) read/write -each field independently. - -**One real bug fixed in the imported file**: the wiki (current, 2025) says the rosewood blowpipe -"is able to shoot up to rune darts," but torka's file capped its `maxDartIndex` at adamant - fixed -the constant (`RUNE_DART_INDEX = 6`, was `ADAMANT_DART_INDEX = 5`) and its doc comment before -deploying, per the standing verify-against-wiki-before-trusting-any-source discipline. - -**What was actually still missing, since torka's contribution only had the special attacks**: -- **Normal attacks**: both blowpipes store ammo *inside the weapon item itself*, not the quiver - - the generic cache-driven ranged fallback (`PvNCombat`/`PvPCombat`) only knows about quiver ammo, - so neither blowpipe could fire a normal attack at all without a dedicated `WeaponMap`. Built - `BlowpipeWeapons.kt` (`content/other/special-weapons/.../ranged/`), mirroring each special - file's own exact logic (dart/scale conservation rolls, 25%/100%-with-serpentine-helm venom - chance for toxic, no venom at all for rosewood per the wiki) but with 1x multipliers instead of - the special's boosted ones. -- **`EquipmentChecks.isSerpentineHelm`** - referenced by the special files but didn't exist; added - (covers the base, charged, and both cyan/red-recolor variants). -- **`obj.rosewood_blowpipe`'s combat data** - the base cache import had no `weaponCategory` or any - `attack_anim_stance*`/`bas_*` params at all (same missing-combat-data pattern as several other - recently-added weapons this session) - added a full `items.toml` override using its own real - named animation (`seq.rosewood_blowpipe_attack`, - confirmed via `gameval_search`) plus the same generic `bas_*` wield-stance set toxic blowpipe - already uses (no dedicated named aliases exist for rosewood's own idle/walk poses). -- **Loading darts/scales**: `BlowpipeAmmo.storeDarts`/`storeScales` existed as pure functions but - nothing called them - a blowpipe could never actually get ammo into it. Built - `BlowpipeCharging.kt` (`onOpHeldU` for every dart-type/blowpipe-variant pair, plus scales for the - toxic family), following the exact same pattern as `WebweaverBowCharging.kt`. **Unload/Uncharge - (returning stored ammo to the inventory) are not wired up yet** - `BlowpipeAmmo.unloadDarts`/ - `uncharge` exist and are ready, just need the actual `onOpHeld*` option hookup; a smaller, - separate follow-up. - -`compileKotlin`/`compileTestKotlin`/`test` all exit 0 project-wide; cache rebuilt (new -`rosewood_blowpipe` items.toml block); server restarted clean. - -**Two follow-up bugs found live, both fixed**: -- **`BlowpipeWeapons` was never added to `RangedWeaponsModule.kt`'s bindings.** Unlike - `PluginScript`s (auto-discovered), `WeaponMap` implementations need an explicit - `addSetBinding(...)` or they're silently never invoked - rosewood blowpipe's normal - attack fell through to the generic ranged fallback, which fails for a different reason (blowpipe - ammo isn't in the quiver) but happens to print the exact same message text ("You are unable to - fire your ammunition."), making it look identical to the dedicated handler's own failure branch. - Fixed; both blowpipes' normal attacks confirmed working live. -- **`Toxic Siphon`'s spotanim was played on the player at `height = 96`** - the same blind-copy- - from-Dragon-claws height pattern fixed on many other specials this session, but the deeper issue - the user caught: `spotanim.toxic_blowpipe_specialattack` (id 1043) is the special's own flying - projectile effect, not a player-cast effect at all - it was being played statically at the - player's feet instead of as the projectile heading to the target. Fixed by passing it as the - `spawnProjectile` spotanim (replacing the dart's own generic travel effect for this hit) instead - of a separate `spotanim(...)` call on the player. The animation/spotanim aliases themselves - (`seq.toxic_blowpipe_special_updated` id 876, the spotanim id 1043) were always correct - only - how they were used was wrong. - -**A real damage bug found live and fixed**: reported as "regular rune dart hits harder than the -same rune dart loaded in the blowpipe," which is backwards - the blowpipe should always hit at -least as hard (it adds its own +20 ranged strength on top). Root cause: the loaded dart's own -`ranged_strength` bonus (rune dart: +26, confirmed via cache) is never counted at all when firing -from the blowpipe. The standard equipment-bonus scan (`WornBonuses.calculate`) only sums params off -items actually worn in a `Wearpos` slot - a blowpipe's darts are packed inside the weapon's own -`vars` bitfield instead (see [[blowpipe-ammo-built-from-torkas-source]]), so they were invisible to -it entirely, even though `BlowpipeAmmo.rangedStrengthBonus(obj)` already existed (from torka's -original file) specifically for this purpose and was simply never wired in anywhere. Fixed with the -same one-line-per-formula pattern as the Eclipse atlatl max hit fix: added -`BlowpipeAmmo.rangedStrengthBonus(source.righthand)` to the `rangedBonus` term in both -`PvNRangedMaxHit`/`PvPRangedMaxHit.computeModifiedDamage` - a safe no-op (returns 0) for every -weapon that isn't a loaded blowpipe, since `BlowpipeAmmo.loadedDart` returns null otherwise. Covers -both the special and normal attacks in one fix, same shared-formula-chain reasoning as the atlatl. -Compiled/tested clean project-wide; no cache changes needed; server restarted clean. - -**Check/Unload/Uncharge built**, confirmed against a real cache dump rather than guessed - the -live wiki cache (`osrs-wiki-cache-utils`) still has the un-league-ified real toxic/rosewood -blowpipe `config/obj` entries, showing the exact op layout: toxic loaded variants -(`iop2=Wield, iop3=Check, iop4=Unload, iop5=Uncharge`), rosewood loaded -(`iop2=Wield, iop3=Check, iop5=Unload` - no Uncharge, since it has no scales, and its removal op -sits at position 5 instead of 4). `param.wear_op1=Check` confirmed the same "Check" option is also -available while worn, replacing the item's default unequip-by-op1 (real Jagex behavior on this -specific weapon - it can still be removed via the equipment interface). Implemented with -`onOpHeld3`/`onOpHeld4`/`onOpHeld5`/`onOpWorn1`, reusing `BlowpipeAmmo.unloadDarts`/`uncharge` -(present in torka's original file, unused until now) for the actual state changes, and -`invAddOrDropType`/`choice2` (same pattern as `TumekensShadowCharging.uncharge`) for returning -ammo to the inventory with a confirmation prompt. - -**A real bug from the first pass, caught live and fixed**: also wired up Check-while-worn via -`onOpWorn1`, since `param.wear_op1=Check` on the real item. `onOpWorn1`'s own doc explicitly warns -it "replaces the default unequip op handling" - and in this engine, op1 while worn is "Remove" -*regardless* of what `wear_op1` claims the real client would show, so registering it made the -Remove button silently run Check instead of unequipping (reported as "the remove option shows -scales and darts, not the check option"). Removed the `onOpWorn1` registration - held `Check` (op3) was -untouched. User confirmed a "Check" option genuinely exists on the equipment-tab screen too, so -this needed fixing properly rather than dropping: switched to `onOpWorn2`, the exact same slot -`TumekensShadowCharging` already uses for its own worn-Check - a real, free slot in this engine -that doesn't touch Remove. - -**Player animation bug found live and fixed**: reported as "character just stands still while the -projectile fires" during Toxic Siphon. Root cause: `seq.toxic_blowpipe_special_updated` (used for -the spotanim fix earlier) was ALSO being played on the player via `anim(...)` - but a real cache -dump (`osrs-wiki-cache-utils`) showed it has none of the markers a real player-body seq carries -(`replaceheldright`, `walkmerge`, `heightoffset` - all present on e.g. Rosewood's own -`rosewood_blowpipe_special_attack` seq, all absent here). It's the flying dart's own model -animation, referenced by the spotanim's `anim=` field, not something to separately trigger on the -player - so calling `anim()` with it did nothing visible on a player skeleton. Fixed by reusing the -weapon's own real throwing animation instead (reads `param.attack_anim_stance1` directly off the -weapon, so it naturally picks the right variant for the ornament kit too), matching how Rosewood -blowpipe's own special already uses its own dedicated player seq. - -**Projectile flew too high, fixed - and a real build-tooling bug found and fixed along the way**: -Toxic Siphon's flying dart used the shared `projanim.thrown` type (startHeight=163/endHeight=146, -same as every other thrown weapon), but this effect's own model looked far too high in the air at -those heights. Tried adding a brand-new dedicated `projanim.toxic_blowpipe_special` entry to -`projectiles.toml` with lower values (to avoid touching the shared type used by every other thrown -weapon) - this doesn't work the way `items.toml`/`param.rscm` custom aliases do: a raw-cache -`projectiles.toml` entry's `id` must already be a registered RSCM alias, not something you can -invent inline; hit `IllegalStateException: rsconfig constant mapping ... not found in table -'projanim'` during `buildCache`. Tried `projanim.dragonfire` (43/31) as an interim -workaround - reported too low. Went back and did it properly: found `.data/gamevals/projanim.rscm` -is a genuinely small, hand-editable alias table (18 lines, unlike `obj.rscm` which is effectively -empty and backed by a merged binary source elsewhere) - added `toxic_blowpipe_special=18` there, -then re-added the dedicated `projectiles.toml` entry (this time it built clean) with a middle- -ground height (95/70) between the too-high shared "thrown" type and the too-low dragonfire -experiment. Reported still slightly low - nudged both up -further (95/70 → 115/85). Still an unverified visual-tuning guess pending live confirmation. - -**Osmumten's fang special: same recurring height=96 bug, only just now caught.** Reported "a bit -too high" - `spotanim.spotanim_weapon_sword_osmumten_special` was still at the blind `height = 96` -copied from Dragon claws early this session, never touched since it wasn't flagged before now. -Dropped to `0`, same fix as every other file that's hit this exact pattern. - -While chasing that, also hit and fixed a **real, reproducible `:or-cache:buildCache` bug**, -unrelated to blowpipes specifically: `MinifyServerCache.kt`'s final step -(`temp.copyRecursively(loc, true)`) intermittently threw `FileAlreadyExistsException` overwriting -`loc`'s own cache files, 100% reproducible from a clean `.data/cache/SERVER/` deletion, a stopped -Gradle daemon, and no other process holding the cache open. Root cause: on Windows, closing the -`CacheLibrary` (line `cache.close()`) doesn't deterministically release its memory-mapped file -handles - JVM `MappedByteBuffer` unmapping isn't guaranteed until GC runs, and there's no explicit -unmap API available on this JDK. Added a `System.gc()` immediately before the overwrite copy; -rebuilt clean on the next attempt. A real, generally-applicable fix - not scoped to this session's -blowpipe work - worth remembering if `buildCache` ever throws this same exception again elsewhere. - -**One real UX quirk found, not resolved**: loading ammo only works as "use blowpipe on scales/darts," -not "use scales/darts on blowpipe" - the reverse direction does nothing, even though `onOpHeldU`'s -own doc claims click order shouldn't matter (the framework is meant to normalize which item is -`first`/`second` regardless of which the player clicks first). Spent a long diagnostic pass on this -(registration-time logging confirmed all handlers register with zero exceptions; interaction-time -logging showed the handler never firing for the "scale on blowpipe" direction) before the user found -the working direction empirically. Not chased further since it's unblocked, but a real, unexplained -discrepancy in this engine's click-order-independence claim - worth another look if it recurs -elsewhere. - -### Poison/venom + shove-stun/bind unlock (resolved - 7 weapons converted) - -Built three small, self-contained services this session, all following the same shape as -`BurnEffectService` above (in-memory, `WeakHashMap`-keyed, self-rescheduling via `WorldQueueList`, -no persistence across logout, no buff-bar/UI integration): - -- **`NpcPoisonEffectService`** (`api/mechanics/toxins/`) - NPC-side poison and venom, reusing - `PlayerPoison`/`PlayerVenom`'s own damage formulas directly (severity decaying 1/tick; venom - escalating 6/8/10.../20 every 30 ticks) since NPCs get poisoned/envenomed identically to players - mechanically - only the "where is this stored" part is new (NPCs have no varps). Unblocked - **Webweaver bow**. Toxic blowpipe and Rosewood blowpipe also needed this, but are still blocked - separately on the missing `BlowpipeAmmo` (see above) - this piece alone wasn't enough for them. -- **`BindEffectService`** (`api/combat/combat-commons/`) - a duration tracker for melee specials - that immobilize a target and/or read how long it's already bound for (`Npc.movementLocked` for - NPCs; a lazy "until" check, no engine flag exists for Player). Deliberately separate from the - pre-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. Unblocked **Zamorak godsword's Ice Cleave** (NPC side; players - still freeze via the existing spell mechanic) and **Blue Moon spear's Break Shackles** (reads/ - breaks the same tracker). -- **`ShoveStunService`** (`api/combat/combat-commons/`) - the Shove special's stun (Dragon spear, - Zamorakian spear/hasta). Verified against the wiki's own "Stun (status)" page while building this: - torka's original file had an invented one-cycle post-stun immunity that doesn't exist in the real - game ("There is no immunity and thus the stuns from Shove can be chained") - removed it entirely - rather than porting the bug forward. Scope limit, documented in the file itself: a real stun - blocks movement, attacking, eating, equipment-changing, and spellcasting, but this only drives - `Npc.movementLocked` for NPCs and exposes a boolean for other code to check - nothing currently - enforces the equivalent for player targets (no existing "block all actions" flag to hook into), so - a stunned player can still act. The knockback itself also uses a plain teleport rather than the - engine's `exactMove` primitive (a smoothed client-side slide) - `exactMove` has zero existing - usages anywhere in this codebase to confirm its timing/direction parameters against, so this - trades away that visual for something unambiguously correct instead of guessing. - -You asked directly whether this batch had gotten test coverage - it hadn't, which broke this -session's own established pattern. Went back and extracted the pure logic out of all four new -services plus the two weapons with real inline math, and wrote tests for each: `BurnStacksTest`, -`ExpiringDurationMathTest` (shared by `BindEffectService`/`ShoveStunService`), `NpcPoisonOverrideTest`, -`UnleashMultipliersTest`, `ImpactMeleeSpecialAttacksTest`. Writing the last one caught a real, -previously-unnoticed bug: Dragon warhammer's NPC defence reduction was `current * 70 / 100`, but the -wiki's own worked example (75 -> 53 -> 38 Defence across two hits) only matches `current - -floor(current * 0.3)` - the two formulas floor at a different point and disagree whenever -`current * 30` isn't a clean multiple of 100. Fixed. - -Also found and fixed two small pre-existing bugs while wiring these three specials up: -- Zamorak godsword's freeze duration was 33 ticks; the wiki is explicit it's 32 ("19.2 seconds"). -- Saradomin godsword's heal/prayer-restore was gated on `impact.damage` (the post-mitigation hit). - The wiki is explicit this is wrong: "calculated from the potential damage of a swing *before* some - types of damage immunities are applied" and "before flat armour is applied" - switched to the - already-known pre-mitigation roll, matching this session's established "real OSRS doesn't clamp - damage after the roll" pattern (this isn't just an engine-diff workaround here - it's the - wiki-documented *correct* behavior). Bandos godsword and Dragon warhammer were also switched from - gating on `impact.damage` to gating on the accuracy roll itself, per the wiki's explicit contrast - ("unlike... Bandos godsword... the dragon warhammer only needs to roll a successful hit"). - -### Missing normal-attack audit (Thunder khopesh's bug wasn't unique) - -You asked whether other converted weapons might have the same "special works, normal attack is -broken/incomplete" gap Thunder khopesh had. Checked it directly rather than guessing: spot-checked -cache animation params (`attack_anim_stance*`) for every recent/limited-release item among the -converted weapons (Bounty Hunter, Leagues, quest-reward exclusives - the same category Thunder -khopesh belongs to). Found and fixed two more real, complete gaps (same as khopesh): -- **Crimson kisten** (June 2026 Maggot King drop) - zero animation params. Fixed with the item's own - real named animations (`seq.human_weapons_crimson_kisten_attack`/`_attack_alt`), not a generic - reuse - matches the wiki's own trivia about which styles kept which animation after a post-release - hotfix. -- **Sunspear** (Blood Moon Rises quest reward) - zero animation params. Only its *special* attack has - a named animation in gameval; its normal swing reuses the generic Spear-category fallback (same - set the rune spear uses). - -And confirmed **Fang of the Hound** has the exact same passive-and-animation gap as Thunder khopesh: -wiki - "every hit... has a 5% chance to cast Flames of Cerberus... without needing to pass a second -accuracy check" - never implemented, and its cache entry is also missing animation params entirely -(same Leagues-exclusive-item pattern). Fixed both pieces the same way as khopesh: a new -`FangOfTheHoundWeapons.kt`, hardcoded animation (no cache dependency), and a -`WeaponAttackManager.rollSpellMaxHit` wrapper (same story as the `SpecialAttackManager` one added -earlier - the underlying `PlayerAttackManager` function already existed). - -Checked several more suspects and ruled them out with real evidence, not assumption: Morrigan's -throwing axe (ranged weapons only need one animation param, `attack_anim_stance1` - already present) -and Voidwaker (fully populated, matches Dragon scimitar's own values exactly) are both fine. -Statius's warhammer is missing its stance-3 slot, but warhammers only have 3 real combat styles -(Pound/Pummel/Block) - there's no 4th style to be missing an animation for, so that's not a bug. - -**Update: did the full systematic sweep.** You found Dogsword had the same bug, which prompted -checking every recently-released weapon's cache data directly instead of waiting for more reports. -Confirmed and fixed **8 items total** with a complete `attack_anim_stance*` gap (all also missing -`weaponCategory`, so all showed "Unarmed"/Punch-Kick-Block before this): Thunder khopesh (both -League and Deadman variants), Fang of the Hound, Crimson kisten, Sunspear, Dogsword (both Echo and -Deadman variants), Burning claws, Arkan blade. Fixed each with the closest real reference: Dogsword -and Burning claws both cite real sounds/animations on their own wiki pages (Burning claws literally -lists "dragonclaws_normal" as its attack sound), so those reused Dragon claws'/a generic -TwoHandedSword's real set rather than a guess. - -Checked and confirmed fine (fully populated cache data, no fix needed): Voidwaker, Morrigan's -throwing axe/javelin, Statius's warhammer, Vesta's longsword/spear, Zaryte crossbow, Blue Moon -spear, Tonalztics of Ralos, Osmumten's fang, Soulreaper axe, Eclipse atlatl, Dual macuahuitl, -Saradomin's blessed sword, Webweaver bow. - -**This is now a real, completed sweep of every recently-released weapon among the tracked 68 - not -just the "obviously similar" ones** - if another one still turns up broken, it'd be a genuine -surprise rather than an expected gap. - -**Correction after your screenshots**: Crimson kisten and Fang of the Hound were still showing -Punch/Kick/Block ("Category: Unarmed") even after the animation fix. Root cause: `weaponCategory` -itself was never set for either (I'd only fixed that for Thunder khopesh) - the combat-style tab is -rendered client-side straight from the item's cache definition, so this genuinely can't be worked -around in server code the way the animation was. Added `weaponCategory="Spiked"` (Crimson kisten) -and `weaponCategory="StabSword"` (Fang of the Hound) to their cache overrides and rebuilt - this -time killing the actual lock-holder (the osrs-mcp tool, not RSProx/the game server) proactively -first, and it went through clean on the first try. Also swapped Fang of the Hound's animation from a -generic sword swing to its own real one, `seq.human_karambit_attack` - you correctly guessed it's -the non-special counterpart to `seq.human_karambit_spec` (same swing, no swipe-up finish), and it -turned up in gameval exactly as described. - -### `BurnEffectService` unlock (resolved - 3 weapons converted) - -Built a real Burn status-effect subsystem from scratch (`api/mechanics/toxins/BurnEffectService.kt`) -since it never existed. Verified against the wiki's own "Burn" status-effect page, not just the -individual weapon pages: 1 damage every 4 ticks, a normal instance lasts 40 ticks (10 total -damage), up to 5 independent stacks active at once with the tick *frequency* staying fixed but the -*amount* per tick equal to however many stacks are currently active, and a 6th application while at -5 stacks is simply discarded. Coded as `HitType.Typeless` (not `Ranged`, despite the wiki calling it -"coded as ranged damage" for NPC-immunity purposes) specifically to match the wiki's explicit -carve-out that Protect from Missiles does **not** reduce it. Self-contained, in-memory (a -`WeakHashMap>` of remaining-tick counts per stack, self-rescheduling -via `WorldQueueList` every 4 ticks) - no buff-bar icon, no persistence across logout, and NPC -ranged-immunity/burn-severity-tier interactions aren't modeled. Same scope tradeoff as this -session's other timed-effect additions (`VestaSpearCombatImmunity`, `MorriganHamstring`). - -Also added `consumeRemainingDamage(target)` - sums and clears every active stack's remaining -damage, ends the burn entirely - needed by Eclipse atlatl's own special attack, which (per the -wiki) converts unspent burn damage into a one-off max/min hit bonus. - -This unblocked **Arkan blade, Burning claws, Eclipse atlatl** - moved out of `disabled-tier-b/`. -Arkan blade needed no other changes (`burns.apply(...)` was already correct). Eclipse atlatl also -had the fake `HitImpactHandler` bug (`queueMagicHit` doesn't have that parameter) and was missing -`SpecialAttackManager.rollMagicalRangedAccuracy` (same story as the melee equivalent earlier - -already fully implemented in `PlayerAttackManager`, just needed the wrapper). - -### Morrigan's throwing axe (resolved) - -The only missing piece was `Player.runEnergyDrainMultiplier[Until]`, which never existed anywhere in -the engine. Added `MorriganHamstring` (`api/player/hit/modifier/`) - an `AttributeKey`-based timed -flag, same shape as `VestaSpearCombatImmunity` - and hooked it into -`PlayerRunUpdateProcessor.decreaseRunEnergy()` (the one real per-tick run-energy-loss calculation) -to multiply the loss by 6x while active. Also had the fake `HitImpactHandler` bug in -`queueRangedHit`; the accuracy roll was already known synchronously, so the hamstring effect is -applied directly instead of waiting for an impact callback. - -### `PvPAreaAttackManager` unlock (resolved - 8 weapons converted) - -Turned out this needed far less new engine work than feared. `AreaMeleeTargetSelector.kt` (the -shared target-picker for every AOE melee/ranged special) was already fully written and -self-contained - it only needed a real `PvPAreaAttackManager` at -`api/combat/combat-scripts/.../org/rsmod/api/combat/player/PvPAreaAttackManager.kt` providing -`canAttack(source, target): Boolean` and `applySecondarySpecialAttack(source, target)`. Both turned -out to be thin wrappers around infrastructure that already exists for the game's *normal* PvP -combat path: -- `canAttack` reuses the existing `Set` hooks (the same wilderness-level- - range/Ferox-Enclave/would-skull-prevention checks `PvPCombatScript`'s own `attemptCombatOp`/`Ap` - already runs for a normal attack) plus the existing `Player.isValidTarget()` check. -- `applySecondarySpecialAttack` replicates what `PvPCombat`'s `applyPkVars`/`applySpecialAttackHooks` - already do for the *primary* target (skull hooks, `setPkVars`, special-attack hooks) - needed - separately because secondary AOE targets never go through `PvPCombat.attack` at all; the special- - attack file calls `manager.queueMeleeHit`/`queueRangedHit` on them directly. - -Placed in the same package/module as `PvPCombat`/`PvPCombatScript` specifically so it could reuse -the module-internal `setPkVars(target: Player)` extension function directly instead of duplicating -its pk-var bookkeeping. - -This unblocked all 8 files that only needed `PvPAreaAttackManager`: **Crystal halberd, Dinh's -bulwark, Dragon 2h sword, Dragon halberd, Dragon crossbow, Rune thrownaxe, Thunder khopesh, Vesta -spear** - moved out of `disabled-tier-b/` along with `AreaMeleeTargetSelector.kt` itself. - -Three real bugs found and fixed while moving these in (none are `PvPAreaAttackManager`'s fault - -pre-existing issues in torka's original files that only a real compile could surface): -- **`HitImpactHandler` isn't a real type anywhere in this engine.** `Dinh's bulwark` and `Rune - thrownaxe` both referenced it (`queueMeleeHit`/`queueRangedHit impactHandler = HitImpactHandler { - ... }`), but neither function actually has an `impactHandler` parameter - grepping the whole repo - found `HitImpactHandler` used only inside still-disabled torka files, never a real declaration. - Same fix as the rest of tonight's Tier B work: the information the handler wanted (whether the - hit actually did damage / whether the accuracy roll succeeded) is already known synchronously - before the hit is queued, since real OSRS doesn't clamp damage after the roll - so both became - plain synchronous checks (`if (damage > 0)` for the shield-bash drain, `if (roll.successful)` for - continuing Rune thrownaxe's ricochet chain) instead of a callback. **Worth checking for the same - pattern in the remaining 14 disabled files before assuming any of them "just needs - `HitImpactHandler`" wired up** - it may not exist to wire up at all. -- **Vesta spear's Spear Wall anim/spotanim used raw cache ints** (`player.anim(8184)`, - `spot = 1627`) against functions that only take `String` RSCM names. Same situation as Crimson - kisten earlier - these two cache-native effects don't have named RSCM aliases in this revision - - fixed with `RSCM.getReverseMapping(RSCMType.SEQ/SPOTANIM, id)`, the same reverse-mapping pattern - already used throughout this file family for cache-only spotanims/projanims. -- **Vesta spear's melee-only immunity was wrong per the current wiki.** Built as - `VestaSpearCombatImmunity` (not the `VestaSpearMeleeImmunity` name torka's file imported) because - the wiki is explicit the effect blocks *both* melee and ranged damage ("the user becomes immune to - melee and ranged attacks for 8 ticks" - ranged immunity was added in a later real-game update). - Wired into `StandardPlayerHitModifier` (checked before the protection-prayer/Power-of-Death - branches, same as `adminGodMode`) since it has to block *all* incoming melee/ranged, not just - special-attack hits. - -## Bugs found live-testing the newly-converted Tier B weapons - -- **Ancient godsword's mark spotanim** - was `target.spotanim` (enemy-anchored), which you spotted - as looking flat/static instead of following the sword like the real wiki screenshot. Same - "combat-slot weapon effects are attacker-anchored" pattern as Dragon claws - switched to bare - `spotanim`. Still looked slightly off after that (floating above/behind the blade); dropped - `height` from 96 (copied blindly from Dragon claws) to 48. **You confirmed this looks good.** - Flagged Armadyl godsword as needing the same careful re-check, since its "looks good" was a much - quicker pass than this one got. -- **Every enchanted bolt effect (all 10, on Armadyl and Zaryte crossbow) was missing its own - activation spotanim entirely** - Ruby's Blood Forfeit was the one you found first, but tracing it - fully turned up the whole set (wiki's own effect names, cross-referenced to `spotanim.xbows_*` - cache assets - Opal "Lucky Lightning", Jade "Earth's Fury", Pearl "Sea Curse", Topaz "Down to - Earth", Sapphire "Clear Mind", Emerald "Magical Poison", Ruby "Blood Forfeit", Diamond "Armour - Piercing", Dragonstone "Dragon's Breath", Onyx "Life Leech"). All target-anchored per their wiki - descriptions ("strikes the target", "appears around the target", etc.) - including Ruby, which - turned out to belong on the *target* (where the blood is drawn from), not the shooter, despite - the "sacrifice" framing; you caught that live. Added `spotanim` as a field directly on the - `ArmadylEnchantedBolt` enum and wired one shared call into `applyEffect`/`zaryteApplyEffect` so - every bolt gets it without duplicating the call per-effect. - - Also fixed: Ruby's self-damage used `player.statSub("stat.hitpoints", ...)`, which silently - changes the stat with **no hitsplat at all** - switched to `takeInstantHit(...)`, matching Dual - macuahuitl's already-correct pattern for the same kind of self-damage. Checked the rest of - tonight's weapons for the same `statSub`-on-hitpoints anti-pattern - this was the only instance. - - **Found, not fixed (same known gap as the deferred Tier B poison/burn work):** Jade (freeze) - and Emerald (poison) currently do nothing against NPCs - `CombatEffects.freeze`/`poison` only - accept `Player` targets, and NPC poison specifically needs the same `NpcPoisonEffectService` - that Toxic blowpipe/Rosewood blowpipe/Webweaver bow are already blocked on. Not a new gap, just - confirmation it extends here too. - - Not yet live-tested. -- **Vesta's longsword's Feint - `::maxhit` bug, same category as Dragon claws/Dual macuahuitl.** - The damage roll (`random.of(range)`) never checked `player.adminMaxHit`, so the cheat silently - did nothing for this weapon's spec - same root cause as every other maxhit bug tonight. Extracted - a `resolveDamage` function (same pattern as everywhere else) and added the check, with 3 new test - cases in the existing `VestaLongswordFeintDamageTest.kt`. Not yet re-tested live. - -## Fixed (real bugs found and corrected) - -- **Armadyl godsword** - `spotanim` had no receiver; switched to `target.spotanim`. You live-tested - this after the fix and confirmed it looks good - kept as target-targeted. **Live-confirmed, but - see the Ancient godsword entry below - that "looks good" was a quick check, not a careful - wiki-screenshot comparison, and AGS's own target-anchored spotanim turned out wrong under that - closer look. Armadyl godsword deserves the same re-check before fully trusting it.** -- **Abyssal bludgeon** - same fix (`target.spotanim`), originally based on torka's own Discord bug - report that the Penance visual played on the attacker instead of the enemy on his branch. You - live-tested it after the Dragon-mace-class revert below and confirmed it's on the enemy, correct. - **Live-confirmed.** -- **~~Dragon dagger, Dragon longsword, Dragon mace, Dual macuahuitl, Granite hammer, Osmumten's - fang, Rune claws, Soulreaper axe~~ - REVERTED, this was a wrong bulk fix.** I generalized from - torka's one specific Abyssal bludgeon complaint into a blanket "self-targeted spotanim = - always a bug" rule and applied it across this whole class without checking each weapon - individually. You live-tested it and the effect was landing on you (wrong) for this group even - after the "fix." Real counter-evidence for the revert: our own verified, live damage-tested - Dragon claws uses the exact same bare `spotanim(..., slot = constants.spotanim_slot_combat)` - pattern and has never had an animation complaint; the standard (non-special) ranged combat code - in `PvNCombat.kt` uses the identical bare pattern for its launch/muzzle-flash effect; and an - earlier, already-committed version of Dragon mace (before torka's contribution) also used bare - `spotanim` - which lines up with you not remembering an issue there before. Conclusion: - `spotanim_slot_combat` without a receiver is a weapon-effect overlay on the *attacker's own* - animation, not a target impact effect - reverted all 8 back to bare `spotanim(...)`. **You - re-tested Abyssal bludgeon, Dragon dagger, Dragon mace, etc. after the revert and confirmed - bludgeon lands on the enemy and the rest are back on the attacker/weapon as expected - all - live-confirmed except Soulreaper axe, which isn't reachable in-game yet (Soul Stacks don't - build up currently, unrelated to this fix) and Osmumten's fang/Rune claws which you didn't - explicitly call out but should still be checked.** -- **Granite hammer height** - separately from the receiver revert above, you flagged the effect as - "a little too high" compared to the real wiki reference screenshot (which shows it low, basically - on the weapon) even with the correct (attacker) receiver. `height = 96` was blindly copied from - Dragon claws when this file was written; the engine's own default is `height = 0` (no vertical - offset). Dropped to `0`. This is a visual-tuning guess, not something verifiable from source like - the receiver was - **needs your re-test to confirm**, not yet live-confirmed. -- **Magic shortbow** - a second issue on the same line as the animation bug: the "launch" spotanim - (the muzzle-flash effect, which - unlike the others above - correctly belongs on the *shooter*, - not the target) was wrongly given a `target.` receiver by my own first-pass fix, and also had a - nullable/non-null type mismatch. Both corrected; matches the same pattern already proven correct - in `PvNCombat.kt`'s standard ranged attack handling. -- **Magic bow** - was using the standard ranged max-hit formula, but the wiki documents Powershot - as a custom formula that ignores gear ranged strength, prayers/void, and Slayer helmet(i) - - only visible Ranged level and the ammo's own ranged strength matter. Reused the already-correct - implementation from Magic shortbow's own Snapshot special (same formula, same constants). -- **Dark bow** - Descent of Darkness (regular arrows) had no upper damage cap at all - (`5..Int.MAX_VALUE`). Wiki confirms both variants cap at 48 (`5..48` / `8..48`). Fixed. -- **Soulreaper axe (Behead)** - two real bugs: - 1. Accuracy and damage were both boosted by the same 6%-per-stack figure. The wiki is explicit: - 12% accuracy per stack, 6% damage per stack - different numbers. Fixed with separate - multipliers. - 2. The wiki also documents a *minimum* damage floor (30% of the boosted max hit at 5 stacks), - which the standard damage-rolling function has no way to express (it only rolls `1..maxHit`). - Implemented a custom roll for this, following the same pattern as Dragon claws' `rollRange` - (including respecting `::maxhit` for testability). -- **Dual macuahuitl (Blood Infusion) - `::maxhit` bug, same category as Dragon claws.** You - reported the spec (19,10=29 total) looking weaker than a maxed regular attack (17,17=34 total) - and asked if it might just be the display. Traced it fully: real Dual macuahuitl's *normal* - attack already deals 2 hits, each independently rolled at HALF the weapon's full max hit - (confirmed in the actual normal-attack file, `content/other/special-weapons/.../ - DualMacuahuitlWeapons.kt`, which calls `rollMeleeDamage` twice with `maxHitMultiplier = 0.5`) - - so "17,17" is two half-hits of a full 34 max. The special's own `normalMax` variable is that same - *full*, un-halved value, and with the existing 25%-total-boost math that gives valid per-hit - ranges of roughly 4-21 and 5-21 - 19 and 10 both fall inside those, so **the formula itself was - never wrong**; 29 total is just an unlucky legitimate roll, not a bug in the multiplier logic. - The actual bug: the special's custom `rollDamage` lambda never checked `player.adminMaxHit`, so - `::maxhit` silently did nothing here (identical root cause to the original Dragon claws bug - fixed earlier this session). Fixed - now forces `range.last` when the cheat is active, so you - should get a real maxed total noticeably above 34 on retest. -- **Soulreaper stack decay** (shared helper, not a weapon file itself) - two bugs found while - cross-checking Soulreaper axe: - 1. It was healing the player 8 HP for every stack lost to *passive decay* (30s of not attacking - with the axe). That heal belongs only to actually using Behead to consume stacks - decay - should be a pure loss. Removed. - 2. The decay interval was 20 cycles (12s); the wiki says 50 ticks (30s). Fixed. - -## Real feature gap found and fixed: Soul Stacks were never generated - -You asked directly whether Soul Stacks were ever implemented correctly - traced it and confirmed: -no. What existed before tonight: Behead (consuming stacks) and decay (losing stacks after 30s of -inactivity), both of which I'd touched earlier this session, plus the Strength bonus from *held* -stacks (`MeleeMaxHitOperations.calculateEffectiveStrength` already read `varp.soulreaper_stacks` -and applied +6% Strength per stack). What was missing: the actual *gain*. No file anywhere -registered a normal-attack handler for the axe at all - it fell through to the generic melee -weapon path, which has no notion of stacks. So stacks could only ever go down, never up. Not -something I broke; a pure gap from before I started, presumably never in torka's patch either. - -Wiki ("Soul stacks" section on the axe's own page) is explicit: a stack is generated on *every* -attack with the axe (even a miss) until five are held, applied *after* that swing's own damage is -calculated (so the swing that generates a stack doesn't benefit from the bonus it just earned). -Implemented as a new `content/other/special-weapons/.../melee/SoulreaperAxeWeapons.kt`, registered -in `MeleeWeaponsModule.kt`: an ordinary Crush attack that, after `queueMeleeHit`, increments the -stack (capped at 5 via a small pure `SoulreaperStackGain.nextStackCount` - tested in -`SoulreaperStackGainTest.kt`) and resets the decay timer. Added the `api.mechanics.toxins` gradle -dependency to `special-weapons/build.gradle.kts` for this. Compiles and tests clean - **not yet -live-tested**, since this is brand new code, not a refactor of something already confirmed working. - -## Ancient godsword's full history (now fully converted, in the live source set) - -- Earlier tonight: the "apply the mark" `HitImpactHandler` gate (on landing the initial hit) - simplified to a synchronous check, since real OSRS doesn't clamp damage after the roll. - - Also checked and **did not change**: I initially suspected `HitType.Typeless` was wrong here - (thought Protect from Magic should reduce the delayed hit), but the primary, current wiki page - is explicit that this damage is deliberately typeless and unaffected by Protect from Magic - - a secondary source I glanced at first was outdated/beta info. Caught before making the change. -- Later tonight (Tier B batch): the *delayed heal* `HitImpactHandler` use (8 ticks later) converted - to the world-queue re-schedule pattern - the file moved out of `disabled-tier-b/` entirely. -- **You reported the mark's spotanim looking wrong** - flat/static, planted on the ground, not - following the sword like the real wiki screenshot shows. The code had `target.spotanim` (enemy- - anchored), which is right for an *impact* effect but wrong for a *weapon-swing* effect like this - one - same "combat slot spotanim should be attacker-anchored" pattern established earlier tonight - for Dragon claws etc. Reverted to bare `spotanim` (attacker-anchored). **Not yet re-tested.** - Heal caps (15%, capped 25 NPC / 15 player) were already correct. - -## Verified correct, no changes needed - -- **Dragon claws** - ours (already independently verified earlier), kept over torka's version - (his used a different "roll a total, divide by 2" algorithm that doesn't look equivalent to the - wiki's own worked example). -- **Abyssal dagger** - 25% accuracy, 15%/5% damage reduction (regular/imbued), single accuracy - roll for both hits, slash defence - all match exactly. -- **Dragon battleaxe** - 10% drain to Attack/Defence/Ranged/Magic, Strength boost = 10 + - (drained/4) - exact match. -- **StatBoostSpecialAttacks** (dragon/infernal/trailblazer/crystal axe, harpoon, pickaxe + - Excalibur) - all the +3 skill boosts and Excalibur's +8 Defence match. -- **Dragon candle dagger** - trivial always-0-damage cosmetic special, correct as written. -- **Ballista (light/heavy)** - 25% accuracy + damage, matches your own live test. -- **Dragon knife** - two independent ordinary throws, explicitly no bonus per the wiki. -- **Dragon thrownaxe** - 25% accuracy only, guaranteed next-tick attack. -- **Granite maul (all variants incl. ornate handle)** - Quick Smash is a plain Crush attack with - no accuracy or damage bonus at all ("unlike most special attacks, Quick Smash does not increase - accuracy" - wiki); code uses `accuracyMultiplier = 1.0, maxHitMultiplier = 1.0`. The 60%/50% - energy cost split between base/ornate-handle variants is read from cache data per-obj, not - hardcoded. Matches. -- **Noxious halberd (Virulence)** - self-buff, no target at all (cures your own poison/venom). - Its bare `spotanim` call was *already* correct - my first-pass bulk fix wrongly "corrected" this - one too, caught by a real compile error (`target` doesn't exist in this function) and reverted. -- **HalberdSpecialVisuals, RangedSpecialAttackEffects** (shared helpers) - simple, correct - utilities, nothing weapon-specific to verify. - -## Newly converted from Tier B tonight (22 weapons, not yet live-tested) - -All confirmed to compile and match their wiki mechanic, but this is brand-new code (not a refactor -of something already proven) - needs a first live test, same as the Soul Stack gain feature. Full -list: Ancient godsword (mark-gate simplified, delayed heal still uses the world-queue re-schedule -pattern), Ancient mace, Abyssal whip, Barrelchest anchor, Brine sabre, Demonbane (Darklight/ -Arclight/Emberlight), Dogsword, Dragon scimitar, Dragon sword, Elder maul, Infernal tecpatl (reuses -Dragon claws' cascade math), Nightmare staff, Saradomin sword, Staff of the dead, Statius -warhammer, Vesta longsword, Voidwaker, Armadyl crossbow, Morrigan's javelin, Seercull, Tonalztics -of Ralos, Zaryte crossbow. - -See "Tier B breakthrough" above for the still-deferred 26 (`disabled-tier-b/`). - -## More converted since (12 weapons, not yet live-tested) - -Four "hard" one-off weapons turned out to need far less than their original categorization implied -- in each case the actual roll/accuracy function already existed somewhere in `PlayerAttackManager`/ -`AccuracyFormulae`, just missing a `SpecialAttackManager`-level wrapper (or, for Sunspear, missing -nothing at all - just a different way of expressing what already existed): -- **Crimson kisten** - the missing `dev.openrune.CrimsonKisten` constants object never existed; - the item *does* have a real RSCM alias in this revision (`obj.crimson_kisten`) despite torka's - comment claiming otherwise, but the cache genuinely lacks a `param_1564` special-energy param - (hence the raw-`Int`-energy `registerMelee` overload). Fixed by inlining the real constants - directly instead of the missing external object. -- **Sunspear** - "rolls with exactly 70% of its maximum accuracy" needed no new engine function at - all: this engine's attack roll is already deterministic (no RNG) and scaled by `multiplier` before - the single random hit/miss sample, so it's just the standard `rollMeleeAccuracy` with a - conditional `0.70` multiplier instead of a separate "fixed roll" mechanism. -- **Saradomin's blessed sword** - only needed `SpecialAttackManager.rollMagicalMeleeAccuracy` - wrapping the already-fully-implemented `PlayerAttackManager.rollMagicalMeleeAccuracy` (found this - out the hard way - first added a duplicate implementation directly in `PlayerAttackManager` and - got a "conflicting overloads" compile error). -- **Fang of the Hound** - same story for `SpecialAttackManager.rollSpellMaxHit`, wrapping the - already-implemented `PlayerAttackManager.rollSpellMaxHit`/`calculateSpellMaxHit`. Confirmed - against the wiki: Flames of Cerberus, base max hit 10, guaranteed cast on a landed special hit, no - separate accuracy check. - -Plus the 8 `PvPAreaAttackManager` weapons: **Crystal halberd, Dinh's bulwark, Dragon 2h sword, -Dragon halberd, Dragon crossbow, Rune thrownaxe, Thunder khopesh, Vesta spear** - see the -"`PvPAreaAttackManager` unlock" section above for what each needed and the bugs fixed while moving -them in. - -## Test files - -None of these files had testable logic separated from the live `SpecialAttackManager` (which has -no test harness anywhere in this codebase), except Dual macuahuitl (torka's own extraction). Same -pattern applied to the rest as it's used: pull the weapon-specific formula into a small pure -object/function that takes its inputs as plain params instead of a `ProtectedAccess` receiver, and -unit test that - the manager-wiring glue stays untested, same as everywhere else. - -**Done (batch 1 - genuine custom math, not just a constant):** -- **Dragon claws** (`DragonClawsDamageTest.kt`) - the four-hit cascade tested against all four wiki - worked examples (35-17-8-9, 0-30-15-16, 0-0-22-23, 0-0-0-46), all four hit-range boundaries, all - four miss-cascade sympathy patterns, and `resolveRange`'s `::maxhit`/edge-case handling. -- **Soulreaper axe** (`SoulreaperAxeDamageTest.kt`) - accuracy/damage multiplier formulas, the 30%- - at-5-stacks minimum floor, and the accuracy-fail/maxhit/random resolveDamage paths. -- **Soulreaper stack decay** (`SoulreaperStackDecayTest.kt`, in `api/mechanics/toxins` - this is - where the class actually lives) - the 50-cycle interval and due/not-due boundary, with the timing - math split out from the `Player`-attached state so it doesn't need a real player to test. -- **Magic bow** (`MagicBowDamageTest.kt`) - the Powershot formula checked against 3 hand-computed - cases (confirming it truly ignores everything but ranged level and ammo strength) plus the - maxhit/random resolveDamage paths. -- **Dark bow** (`DarkBowDamageTest.kt`) - both variants' range/multiplier constants, and the - floor/cap clamping behavior (below floor, above cap, inside range, and a miss staying 0). -- **Dual macuahuitl** (existing `DualMacuahuitlDamageTest.kt`, extended) - added 3 cases covering - tonight's `::maxhit` fix (was previously silently ignoring the cheat, same root cause as the - original Dragon claws bug); the fix itself required extracting the inline `when` block into a - testable `resolveDamage` function first, same pattern as everywhere else here. - -**Batch 2 done.** Went through every remaining Tier A weapon (Abyssal dagger, Armadyl godsword, -Dragon mace, Dragon longsword, Granite hammer, StatBoost weapons, Ballista, Dragon knife, Dragon -thrownaxe, Rune claws, Granite maul, Dragon candle dagger, Noxious halberd - Abyssal bludgeon, -Dragon battleaxe, and Osmumten's fang already had tests from earlier). Most turned out to be pure -"pass a literal constant to the shared manager" with nothing to regression-test beyond what's -already wiki-verified above - extracted and tested only the ones with genuine derived logic: -- **Abyssal dagger** (`AbyssalDaggerTimingTest.kt`) - the second hit's delay differs by target type - (1 tick vs players, 2 vs NPCs). -- **Ballista** (`BallistaAnimationTest.kt`) - the 4-way animation choice (ornamented x target type). -- **Dragon knife** (`DragonKnifeVariantTest.kt`) - poisoned-variant detection from the item name. -- **Granite maul** (`GraniteMaulStyleTest.kt`) - the combat-stance-to-attack-style mapping. - -The rest (Armadyl godsword, Dragon mace, Dragon longsword, Granite hammer, StatBoost weapons, -Dragon thrownaxe, Rune claws, Granite maul's own damage roll, Dragon candle dagger) are flat -manager calls with literal multipliers - no test added, nothing there to catch a regression that -the wiki verification above didn't already confirm. Noxious halberd's toxin-priority branch -(venom > poison > neither) depends on live `PlayerVenom`/`PlayerPoison` state rather than pure -math, so it wasn't a good extraction candidate either. - -## Live-test fixup round: Dogsword's real animation, height tuning, and a real energy crash - -**Dogsword's animation was cache-correct but visually wrong.** The earlier `items.toml` fix (generic -`TwoHandedSword` category swing, `seq.human_dhsword_slash`/`_chop`) matched what a real plain 2h -sword uses and fixed the "Category: Unarmed"/Punch-Kick-Block bug, but looked like a plain sword -swing, not a godsword's. Real Bandos/Saradomin/Zamorak godswords use different raw animation IDs -(`7045`/`7054`/`7055`, sounds `3847`/`3846`, confirmed via `cache_search` on the real Bandos -godsword) that have **no named RSCM alias** in this cache revision (`cache_search type=seq -id=7045` returns an empty `debugName`). Since `items.toml` params need string names, built -`content/other/special-weapons/.../melee/DogswordWeapons.kt`: a normal-attack `WeaponMap` entry for -`obj.echo_godsword`/`obj.deadman_dogsword` that plays the real animation via -`RSCM.getReverseMapping(RSCMType.SEQ, rawId)` at runtime instead of a cache param - same technique -already used for Vesta spear/Crimson kisten's specials. `weaponCategory` in `items.toml` is still -what fixes the combat-tab UI and stays necessary; the code-side swing now overrides the visual on -top of it. - -**Found and fixed two duplicate `[[item]]` blocks in `items.toml`** while working the Dogsword fix: -`obj.bone_claws` and `obj.echo_godsword` each had two separate override blocks (one pre-existing, -one added by an earlier fix pass tonight, without noticing the first). The loader apparently merges -duplicates by key rather than erroring, so nothing broke, but this needed cleaning up regardless - -merged each pair into one block (keeping `tradeable = false` and `param.attackrate` from -`echo_godsword`'s original, pre-existing entry) and deleted the redundant leftovers. - -**Height tuning round 2** (same "blindly copied `height = 96` from Dragon claws" pattern as before, -still only fixed on files the user explicitly confirmed too high via live testing - see the earlier -"systemic `height = 96`" note; still not a blanket fix across all remaining files): Bandos godsword, -Saradomin godsword, Zamorak godsword, Dragon warhammer (all four in `ImpactMeleeSpecialAttacks.kt`), -Elder maul (both the launch and target-impact spotanim), Soulreaper axe - all `96` to `48`, each -with the same "unverified visual-tuning guess" disclaimer comment. Confirmed correct and -**deliberately left untouched**: Dragon dagger, and Dragon hasta's own Shove/Unleash spotanims -(user specifically called these out as the one exception that isn't too high, unlike everything -else) - plus whichever "mace" the user tested (ambiguous between Dragon mace and Ancient mace in -their message; left both alone rather than guess wrong on a confirmed-correct file). - -**Real crash fixed: "Not enough special energy to take" disconnecting the player.** Reported live -against Dragon spear's Shove (`PvNCombat.attackMelee:77` → `activateMeleeSpecial` → -`SpecialAttackEnergy.takeSpecialEnergy` threw `IllegalArgumentException`, twice, ~16s apart, same -test player). Investigated and ruled out: Dragon spear's real registered cost is `250` (25%, via -cache enum 906/`sa_energy_requirements`, confirmed identical for `obj.dragon_spear` id=1249 and -both `obj.zamorak_spear`/`obj.zamorak_hasta`, all fed through the same `Shove` instance) - well -above the `< 10` "specialized/self-managing" threshold, so `activateMeleeSpecial`'s normal -`hasSpecialEnergy` pre-check does run for it; `Shove` doesn't self-manage energy at all and never -touches `specialEnergyVarp`; no duplicate registration or item-id collision exists for -`obj.dragon_spear` itself. Given the pre-check and the deduction are separated only by `Shove`'s own -body (which never spends energy), the only way this throws is if the player's special energy -changed between those two points - couldn't pin the exact interleaving (a genuine tick-overlap race -under rapid clicking is the most plausible candidate, given both crashes were immediately -reproducible), so rather than keep chasing an exact trigger, **hardened all three -`activateXSpecial` functions in `api/combat/combat-scripts/.../PlayerCommons.kt`** (melee, ranged, -magic - identical shape in all three) to re-validate `hasSpecialEnergy` immediately before calling -`takeSpecialEnergy`, instead of taking unconditionally whenever the special returned `true`. This -closes the entire crash class regardless of its root cause: worst case now is a special executing -for free once, never an uncaught exception mid-tick. `compileKotlin`/`compileTestKotlin`/`test` all -exit 0; cache rebuilt clean; server restarted with zero boot errors. - -## Live-test fixup round 3: Dogsword's holding stance, height=0, Fang's proc effect - -**Dogsword's swing animation was right but the idle/wield "holding" pose wasn't.** Root cause: -`weaponCategory="TwoHandedSword"` matches a generic 2h sword's combat-tab text ("2h sword") but -`WeaponCategory.kt` has a distinct `GodSword` entry (id 23) with its own note that equipping one -re-sets a client-side wield-stance varp (357) from `10` to `23` via the item's own op script - real -godswords use `GodSword`, not the generic category, even though both display identically in the -combat tab. Switched `obj.echo_godsword`/`obj.deadman_dogsword` to `weaponCategory="GodSword"`. - -**Height=48 (this round's earlier guess) was still too high per live feedback** - dropped to `0` -(ground level) on every file touched in the two height-fixing rounds tonight: Arkan blade, Burning -claws, Morrigan's throwing axe, Bandos/Saradomin/Zamorak godswords, Dragon warhammer, Elder maul -(both spotanim), Soulreaper axe. Still the same discipline as before - only files already flagged -live, not a blanket sweep of the remaining ~30 files still at `height = 96`. - -**Fang of the Hound's Flames of Cerberus passive** (the on-hit proc from its *normal* attack, not -its special) had the same blind `height = 96` on the spotanim it plays on the target - dropped to -`0` per the same live feedback. - -Compiled clean, cache rebuilt, server restarted with zero boot errors. - -## New feature: melee weapon poison ((p)/(p+)/(p++) daggers, spears, hastae) - was entirely unbuilt - -Reported live: Dragon dagger(p)'s Puncture special appeared to poison the training dummy -*instantly*, unlike Webweaver bow's own poison (already fixed this session, correctly delayed 18s). -Investigation found something more basic than a second instant-hit bug: **no code anywhere applied -weapon poison for melee "(p)" weapons at all.** Grepped every poison call site in `api/` and -`content/` - the only callers of the poison system were the boss-effect DSL, Zaryte/Armadyl -crossbow specials, Webweaver bow's special, and Noxious halberd's normal attack. `items.toml` has -no poison-chance/severity param on any dagger/spear/hasta `(p)` variant, and -`DragonDaggerSpecialAttack.kt` itself has zero poison code. So the "instant splat" wasn't a second -instance of the already-fixed bug - it was something else entirely (unconfirmed; possibly a -misread of Puncture's own two close-together damage hits). Whatever it was, real weapon poison for -melee weapons was simply missing content, confirmed via the wiki (`Weapon poison`, -`Weapon poison(+)`, `Weapon poison(++)`): melee attacks apply poison at a 25% chance (1/4) on any -successful hit, dealing 4/5/6 initial damage per tier. - -Built it: **`WeaponPoisonEffect`** (`api/mechanics/toxins/`) rolls the chance and, on success, -calls the already-existing `PlayerPoison.tryPoison`/`NpcPoisonEffectService.apply` - no new damage -math needed, since both were already fixed earlier this session to start the 30-tick recurring -timer instead of hitting instantly. Tier is read directly off the weapon's own RSCM alias suffix -(`_p`/`_p+`/`_p++`, exposed via `ItemServerType.internalName`) rather than a new `items.toml` param -per item - the real cache already carries this consistently across ~90 poisoned melee weapon -variants (bronze through dragon daggers/spears, keris, bone dagger, abyssal dagger, etc.), so -covering all of them needed zero toml edits. Wired into three call sites: `PvNCombat.attackMelee` -and `PvPCombat.attackMelee`'s generic fallback path (covers every dagger/spear/hasta `(p)` variant -that doesn't have its own dedicated `WeaponMap`, which today is all of them), plus -`DragonDaggerSpecialAttack.kt`'s two Puncture hits directly (specials aren't exempt from weapon -poison in real OSRS - each of Puncture's two hits rolls independently). Added -`implementation(projects.api.random)` to the toxins module's `build.gradle.kts` (previously -unused there). `compileKotlin`/`compileTestKotlin`/`test` all exit 0 project-wide; cache rebuilt -(no toml changes needed for this part); server restarted clean. - -**Deliberately out of scope for this pass**: ranged poisoned ammo (arrows/bolts/darts/javelins/ -knives `(p)`) - the tier lives on the *ammo* item, not the weapon, and consumption runs through -`RangedAmmoManager`'s separate ammo-detraction path instead of the melee fallback above. Same -25%/12.5% chance split and 4/5/6 vs 2/3/4 damage split per the wiki, just a different, larger hook -point - not built yet. - -## Ranged weapon poison (arrows/bolts/darts/javelins/knives (p)) - built - -The deferred half of the melee weapon poison work above. Added `WeaponPoisonEffect. -rollOnRangedHit(source, target, ammo, damage)` - same wiki-verified math (12.5% chance / 1-in-8, -initial damage 2/3/4 per tier), reusing the already-existing `WeaponPoisonTier.rangedDamage`/ -`RANGED_CHANCE_DENOMINATOR` values that were defined but unused since the melee-only pass. Tier -resolution is identical to the melee path (RSCM alias suffix), just applied to whichever item was -actually consumed as ammo - the quiver item for bows/crossbows, or the wielded item itself for -thrown weapons (darts/knives/javelins), matching how `PvNCombat`/`PvPCombat`'s own generic ranged -fallback already resolves `weaponType` for ammo validation. Wired into that same fallback (covers -every standard bow/crossbow/thrown weapon using poisoned ammo automatically) plus -`DragonKnifeSpecialAttack.kt`'s Duality special specifically. - -**A second "half-wired" file found**: Dragon knife's special already had real poison *detection* -(`DragonKnifeVariant.isPoisoned`, checking the item's display name for "(p" to pick a themed -travel spotanim/animation) but never actually called into the poison system at all - the visual -distinction existed with no mechanical backing. Added `poison.rollOnRangedHit(...)` after each of -Duality's two independently-thrown knives, same as Dragon dagger's Puncture got for melee. This is -the second time this exact pattern (a weapon "looks" poison-aware but the real poison call was -simply never added) has turned up this session - worth checking any other weapon with its own -poison-flavored visual branching for the same gap. - -Added ranged-damage assertions to the existing `WeaponPoisonTierTest`. `compileKotlin`/ -`compileTestKotlin`/`test` all exit 0 project-wide; no cache rebuild needed (pure Kotlin); server -restarted clean. - -## Eclipse atlatl: real ammo-category bug found, deeper gaps documented (not fixed) - -User reported the Eclipse atlatl "doesn't work at all" for normal attacks - the special -(`EclipseAtlatlSpecialAttack.kt`) was already fully built and registered, so this was specifically -about the weapon's ordinary ranged attack. Root cause, confirmed via `items.toml` + wiki + a real -category id lookup: `obj.eclipse_atlatl`/`obj.br_eclipse_atlatl` had `weaponCategory="Bow"` but no -`param.required_ammo` override, so ammo validation (`RangedAmmunition.validateArrows`) fell back to -`category.arrows` - meaning it demanded real arrows in the quiver instead of the atlatl's actual -ammo, `obj.atlatl_dart` (id 28991, real category `category.atlatl_dart` = 1915, confirmed via -`category.rscm`). Any player without arrows equipped (i.e. everyone actually using atlatl darts as -the wiki describes) would always get "There is no ammo left in your quiver." Fixed: added -`"param.required_ammo"="category.atlatl_dart"` to both item entries. Verified in the rebuilt cache -directly (`cache_search` on item 29000 shows `params={..., 65415=1915}`, and `65415` is confirmed -as `required_ammo` in `param.rscm`). - -**Also needed a second fix after the ammo change**: the real ammo item, `obj.atlatl_dart`, had no -`param.proj_travel` at all (no flying-dart spotanim), so `PvNCombat`/`PvPCombat`'s generic ranged -fallback bailed with "You are unable to fire your ammunition." the moment ammo validation passed. -Added `"param.proj_travel"="spotanim.vfx_atlatl_projectile_01"` (the real atlatl dart travel -effect, found via `gameval_search table=spotanim query="atlatl"`). Confirmed live: normal attacks -and the special both fire correctly now. - -**Max hit formula: fixed.** Per the wiki, the atlatl's max hit is uniquely based on the player's -**melee Strength level/strength bonus**, not ranged - "a unique mechanic among ranged weapons" - -while accuracy still rolls off ranged bonus/level as normal. Confirmed via source read that -`PvNRangedMaxHit`/`PvPRangedMaxHit.computeModifiedDamage` (the single shared formula both the -special's `manager.calculateRangedMaxHit` call and the normal-attack fallback's -`manager.rollRangedDamage` → `rollRangedMaxHit` → `calculateRangedMaxHit` chain both go through) -always pulled `bonuses.rangedStrengthBonus(source)` with no override for any weapon. Fixed with one -conditional branch per formula class (`EquipmentChecks.isEclipseAtlatl(source.righthand)`), -matching the exact style already used there for Twisted bow/Dragon hunter crossbow/etc. one-off -weapon behavior. New `EclipseAtlatlMaxHit.computeBaseDamage` (`api/combat/combat-formulas/.../ -maxhit/ranged/`) reuses `PlayerMeleeMaxHit.calculateEffectiveStrength`/`calculateBaseDamage` -(literally identical formula shape to the ranged version, just different inputs) with the wiki's -other two atlatl-specific quirks folded in: melee strength prayers (Piety etc., not ranged ones) -and, per the wiki's own trivia, void bonus reads the *ranged* void helm rather than the melee one -even though the rest of the formula is melee-shaped. No melee combat style exists for this weapon -(only Accurate/Rapid/Longrange, all ranged-style), so the strength side uses the flat `+8` baseline -every style implicitly gets - no style grants a strength bonus here. Single fix point covers both -the special and any normal attack, since they share the same formula chain - no separate -`WeaponMap` needed. Not modeled (real but more niche per the wiki: "uses the melee bonuses from -the slayer helmet and salve amulets"): those two amulet/helm procs still apply their *ranged* -percentage in `RangedMaxHitOperations.modifyBaseDamage` for this weapon instead of their melee -one - a smaller, separate gap, not fixed this pass. - -**Set effect (20% burn chance): fixed.** Correctly pointed out live: the special's own "consume -remaining burn damage" bonus can never do anything for a solo player without this - there was -simply nothing else applying Burn from the atlatl itself. Built `EclipseAtlatlBurnEffect` -(`api/mechanics/toxins/`), same shape as `WeaponPoisonEffect`: on a successful ranged hit, if the -full Eclipse Moon set (helm/chestplate/tassets/atlatl) is worn, roll 20% (`randomBoolean(5)`) and -call the already-existing `BurnEffectService.apply`. Wired into the same `PvNCombat`/`PvPCombat` -ranged-attack fallback as the max hit fix, right after `queueRangedHit` - deliberately *not* wired -into the special itself, since the special's whole design is to consume burn built up between -casts, not generate more (would be self-consuming and pointless). Also deduplicated the -full-set-worn check: it used to be a private extension in `EclipseAtlatlSpecialAttack.kt`; moved -to `EquipmentChecks.isEclipseMoonSet(helm, top, legs, weapon)`, matching the existing -`isDharokSet`/`isToragSet`-style pattern in that file, so both the special and the new passive -share one definition. Full project `compileKotlin`/`compileTestKotlin`/`test` all pass; no cache -rebuild needed (pure Kotlin); server restarted clean. - -## Test coverage: two pure-math functions extracted and covered - -Added `WeaponPoisonTierTest.kt` (suffix-detection logic for the new `WeaponPoisonEffect` above, -including a regression guard against a name that merely ends in a literal "p" without being a real -poison suffix, e.g. `iron_dagger_pouch`). Also extracted `AncientGodswordSpecialAttack.kt`'s Blood -Sacrifice heal-cap math (15% of target base HP, further capped by a flat player/NPC ceiling, never -exceeding the damage actually dealt) out of the `WorldQueueList`-coupled `heal()` method into a -standalone `BloodSacrificeHeal.healAmount(...)` object, then added `BloodSacrificeHealTest.kt` -covering all three cap interactions (damage-bound, percent-bound, flat-cap-bound) plus the zero -case. Broader batch test-writing across the remaining untested special-attack files (most of which -are flat accuracy/damage multipliers with little to extract, per the earlier note under "Test -files") is still open - this pass only covered what came up naturally while doing the poison work. - -## Soulreaper axe (o) missing normal-attack combat data (resolved) - -Same pattern as the earlier "missing normal-attack audit" and Leagues-exclusive-item findings: -`obj.soulreaper_axe_orn` (the ornament kit variant, id 33335) had no `weaponCategory` or -`attack_anim_stance*`/`bas_*` params in `items.toml` at all, so it fell through to the engine's -generic unarmed-shaped fallback for normal attacks despite `SoulreaperAxeSpecialAttack.kt` already -correctly handling the ornament variant for the special. Fixed by adding a second `[[item]]` block -for `obj.soulreaper_axe_orn`, copying the base `obj.soulreaper` block's params verbatim -(`weaponCategory="Axe"`, `seq.ancient_axe_crush`/`_slash` attack anims, `seq.ancient_axe_walk`/ -`_idle` stance anims, `equipment_sound=2232`, `attackrate=5`, `defend_anim="seq.human_unarmedblock"`). -Required a full cache rebuild (`items.toml` change). Not yet live-tested by the user. - -## Webweaver bow: added Check (charges) and Uncharge, neither existed before - -User wasn't sure whether a partial "Unload" exists on this item at all ("we cant unload it i -think, well idk if u can do that gotta check wiki") and separately couldn't check remaining -charges anywhere. Settled both against a real cache dump -(`mcp__osrs-wiki-cache-utils__search_cache`, `config/obj`) rather than guessing: -`wild_cave_webweaver_charged` (27655) has `iop2=Wield, iop3=Check, iop5=Uncharge` - no partial -Unload exists, only an all-or-nothing Uncharge, matching the single pooled-charge model -`ObjChargeManager` already uses for this item (one `varobj.charges_16383` count, not a -dart/scale-style split like the blowpipe). The uncharged variant (`wild_cave_webweaver_uncharged`, -27652) only has `iop2=Wield, iop3=Dismantle` - no Check/Uncharge there, correctly. - -Added to `WebweaverBowCharging.kt`: `onOpHeld3` (inventory Check), `onOpWorn2` (worn Check - see -below), and `onOpHeld5` (Uncharge). `checkCharges` just reports the current `ObjChargeManager` -count. `uncharge` mirrors `TumekensShadowCharging.uncharge`'s exact shape: bail if already at 0 -charges, bail if inventory has no free space, `choice2` confirmation dialog, then -`charges.removeAllCharges(...)` and `invAddOrDrop(objRepo, ETHER_ITEM, removed)` to return the -revenant ether (`obj.wild_cave_shard`). - -Used `onOpWorn2` rather than `onOpWorn1` for the worn Check, on purpose, even though the real -client shows Check at `wear_op1` for this item - same lesson learned the hard way on the toxic -blowpipe earlier this session (`onOpWorn1`'s own doc says it replaces this engine's default -unequip/Remove handling, so registering it for anything else silently breaks Remove). `onOpWorn2` -is the same safe slot `TumekensShadowCharging` already uses for its own worn-Check, so this was -applied preemptively instead of being found as a live bug again. - -Full project `compileKotlin`/`test` passes. Cache rebuilt (for the Soulreaper axe items.toml -change - the Webweaver bow change is pure Kotlin and didn't need it). Server restarted clean, zero -errors in the boot log. Neither fix has been live-tested by the user yet. - -## Systematic sweep: 29 more "special works, normal attack doesn't" weapons found and fixed - -Prompted by the Soulreaper axe (o) fix above - user asked "is there no special attack we have on -unimplemented weapons anymore, cuz soulreaper (o) was like that." Answer: no, there were 29 more. -Found them precisely (not a guess-and-check sweep) by extracting every `obj.*` item alias -referenced anywhere in `content/other/special-attacks/src/main/kotlin` (i.e. every weapon that -already has a *working special attack registered*) and cross-referencing that list against every -`items.toml` block that has `weaponCategory` set but no `attack_anim_stance1` param at all - the -exact same shape of bug as Soulreaper axe (o). This is a stronger technique than the earlier -2026-08-29 sweep (see [[leagues-exclusive-items-missing-combat-data]]), which only checked base -item names and missed the "(o)"/corrupted/deadman sibling ids entirely. - -**Fixed by mirroring the real base weapon's `items.toml` block** (same technique as Soulreaper axe -(o) - merge base weapon's animation params onto the variant, but *preserve* any of the variant's -own already-set overrides like a different `attackrate`, since several of these are deliberately -rebalanced for Deadman mode): - -- **Bounty Hunter "corrupted" dragon weapons (17 items)**: dagger + (p)/(p+)/(p++), spear + - (p)/(p+)/(p++), 2h sword, battleaxe, halberd, longsword, mace, scimitar, shortsword, warhammer, - and the dragon crossbow (`obj.xbows_crossbow_dragon`). All had `weaponCategory` and often - `equipment_sound`/`attackrate` already set (correctly, sometimes with BH-specific values) but - zero animation params at all - would have fallen back to the punch/kick unarmed animation on - every normal hit despite each one's dedicated special attack already working. -- **Deadman mode weapons (8 items)**: `deadman_ags`/`deadman_blighted_ags` (mirrors `obj.ags`, - the real Armadyl godsword alias - not `armadyl_godsword`), `deadman_darkbow`/ - `deadman_blighted_dark_bow` (mirrors `obj.darkbow`), `deadman_voidwaker`/ - `deadman_blighted_voidwaker`, `deadman_nightmare_staff_volatile`/ - `deadman_blighted_volatile_staff` (mirrors `obj.nightmare_staff_volatile`). -- **`dinhs_bulwark_ornament`** and **`br_dual_macuahuitl`**: same pattern, mirrored from - `obj.dinhs_bulwark`/`obj.dual_macuahuitl`. -- **`obj.toxic_sotd_deadman`/`obj.toxic_sotd_charged_deadman`**: these had *no items.toml block at - all* (not caught by the weaponCategory-based sweep at all - found separately by checking every id - `StaffOfTheDeadSpecialAttack.kt` registers). Added brand new blocks mirroring - `obj.toxic_sotd`/`obj.toxic_sotd_charged`. -- **`obj.emberlight`**: real DT2 weapon (The Whisperer reward), genuinely has *no* unique - normal-attack animation anywhere in the cache - confirmed via both `gameval_search` and - `search_cache` kind=`config/seq`, only its special-attack animation - (`seq.human_weapon_emberlight_01_spec`) and vfx exist. Wiki's own combat-style table (Chop/Slash - Accurate, Slash Aggressive, Lunge Stab Controlled, Block Slash Defensive) matches the same - slash-heavy/one-stab-style shape as Voidwaker and Dragon scimitar exactly, so it reuses their - same generic `seq.human_sword_slash`/`seq.human_sword_stab`/`seq.human_sword_def` set - real OSRS - itself doesn't always give a new weapon bespoke swing frames, only a special. -- **`obj.bh_dragon_claws_corrupted`**: this one wasn't just a missing-animation bug - its special - attack was *never registered at all* in `DragonClawsSpecialAttack.kt` (only `obj.dragon_claws` - was). Added the missing `registerMelee("obj.bh_dragon_claws_corrupted", DragonClaws(manager))` - line plus the same animation mirror as the others. - -**Found but deliberately not fixed this pass** (lower confidence, need their own look): 3 pairs of -skilling-tool "grip state" variants referenced by `StatBoostSpecialAttacks.kt` -(`trailblazer_reloaded_axe/_empty/_no_infernal`, and the analogous harpoon/pickaxe forms, plus -`crystal_axe_2h`/`dragon_axe_2h`/`3a_axe_2h`) - the `_2h` suffix's exact semantics are unconfirmed -(possibly a cosmetic two-handed grip render used only during the woodcutting animation, not a real -combat-equipped state), so mirroring combat data onto them without checking that first risks -animating something that's never actually the equipped fighting state. Also -`arclight_inactive`/`crystal_axe_2h_inactive` (pre-activation states, correctly out of scope - same -as barrows' "broken" items) and `osb10_dragon_candle` (holiday novelty, not a real combat weapon). - -Full project `compileKotlin`/`test` passes. Cache rebuilt (all these are items.toml/new-block -changes). Server restarted clean, zero errors in the boot log. None of these 30 fixes (29 items + -the Dragon claws BH registration) have been live-tested by the user yet. - -### AGS special height (resolved) + a proper family-based re-sweep (4 more registration gaps found) - -**Armadyl godsword special height fixed** - same `height = 96` bug as Osmumten's fang, in -`ArmadylGodswordSpecialAttack.kt`'s single shared `smash()` function. Dropped to `height = 0`. -Covers all five registrations that share it (`ags`, `br_ags`, `deadman_ags`, `agsg`, -`deadman_blighted_ags`) in one fix. Pure Kotlin, no cache rebuild needed. - -**User live-tested `bh_dragon_claws_corrupted` and confirmed the special still said "not -implemented"** despite the registration fix being verifiably deployed (checked the actual compiled -bytecode in both the jar and the classes dir the server loads from, not just the source - it was -there). Turned out the user was actually wielding a *different* item, `obj.deadman_blighted_dragon_claws` -(28534, "Dragon claws (cr)" for Deadman mode) - a sibling variant I hadn't touched at all, not the -Bounty Hunter one (28039) I'd fixed. Both display similarly enough to look like the same bug report. - -**This prompted redoing the sweep properly** - the previous technique (cross-reference items -*already referenced somewhere* in special-attacks `.kt` files against items.toml animation gaps) -structurally cannot catch a weapon family where SOME siblings are registered but others were simply -forgotten entirely, since the missing ones never show up as "referenced" in the first place. New -technique: extract the exact `obj.*` id argument passed to every `registerMelee`/`registerRanged`/ -`registerMagic`/`registerInstant` call, strip known variant prefixes (`bh_`, `deadman_`, -`deadman_blighted_`, `br_`, `cert_`) and suffixes (`_corrupted`, `_ornament`, `_orn`, `_imbue`, -`_inactive`) to get each weapon's "family root", group registered ids by root, then for every root -grep `items.toml` for every id containing that root and diff against the *global* registered set -(not just that root's own subset - needed to avoid false positives from ids like -`dragon_dagger_p`/`_p+`/`_p++` each becoming their own separate root under this stripping scheme). - -**Real gaps found and fixed** (registration only - all four already had correct normal-attack -animation data in `items.toml`, so this was purely the special-attack side): -- `obj.br_dragon_claws`, `obj.dragon_claws_ornament`, `obj.deadman_blighted_dragon_claws` - the - full Dragon claws family, added to `DragonClawsSpecialAttack.kt` alongside the two already there. - `deadman_blighted_dragon_claws` additionally needed the items.toml animation mirror too (same - gap pattern as the BH corrupted one, just never caught since it isn't referenced by the special - file at all - the old sweep technique's blind spot exactly). - -**False positives correctly ruled out before touching anything** (multi-line `registerX(...)` calls -where the id string is on a line after the opening paren defeat a naive single-line grep, but the -registration is real): `obj.bh_dragon_longsword_imbue`, `obj.morrigans_javelin_bh`, -`obj.statius_warhammer_bh`, `obj.vestas_spear_bh` were all already correctly registered. - -**One genuinely interesting false positive, not a multi-line issue**: `obj.daganoth_cave_magic_shortbow` -looked like a real missing Magic shortbow variant (has full combat data, distinct RSCM alias) and -was added to `MagicShortbowSpecialAttack.kt` - which **crashed the server on boot**: -`IllegalStateException: Weapon already has a special attack mapped: 6724`. Turned out -`obj.daganoth_cave_magic_shortbow` and Seercull (registered by raw numeric id `6724`, since -Seercull itself has no RSCM alias in this revision - see the comment already in -`SeercullSpecialAttack.kt`) are literally **the same cache item under two different names** - -Seercull's real internal cache name apparently *is* `daganoth_cave_magic_shortbow` (Jagex dev -naming, presumably referencing where/how it was originally conceived). Confirmed via -`gameval_search`: both resolve to id 6724. Reverted the bad registration immediately. **Lesson**: -a distinct RSCM alias with full combat data is not proof of a distinct underlying item - two -aliases can share one numeric id, and `SpecialAttackRegistry.add` throwing on `AlreadyAdded` at -startup is a hard crash (not a soft failure), so this class of mistake is at least loud and -immediate rather than silently wrong. - -**A second real issue hit during the crash-recovery restart**: after reverting and restarting, the -new server process failed with `BindException: Address already in use` on ports 8080/43594 - the -*previous, crashed* server process didn't fully release its listening sockets on the way down. -Had to find and kill the zombie PID directly (`netstat`, not just re-running the normal kill-by-port -step, since the first restart attempt's failure happened AFTER the port had already been bound but -BEFORE "Server ready", so the standard "kill whatever's on 43594 first" step from earlier in the -same restart cycle didn't apply to it). - -**Deliberately not fixed, flagged for its own dedicated pass**: `crystal_halberd`'s huge charge-tier -family (`crystal_halberd_100` through `_1000`, `_new`, `_inactive`, plus a whole parallel -`nzone_crystal_halberd_*` set) - none of the ~20 sibling ids are registered anywhere. This is a -different, bigger problem than a forgotten `registerMelee` call: crystal halberd's charge/degrade -system was already pulled out to `disabled-tier-b/` earlier in this project's history (per git -status), meaning this was a deliberate scope cut, not an oversight, and deserves its own look at -the charge-tier damage scaling rather than a blind mirror-and-register pass. Also skipped: -`obj.dummy_dragon_knife_off`/`_p` (internal offhand-slot placeholders, not real player items) and -`obj.macro_broken_dragon_pickaxe` (anti-macro detection fixture, not a real item). - -Full project `compileKotlin`/`test` passes. Cache rebuilt (the `deadman_blighted_dragon_claws` -toml addition needed it). Server restarted clean after resolving the crash and the port conflict, -zero errors in the boot log. - -## Correction: the "trailblazer_*_no_infernal" family is NOT a cosmetic grip render - -User reported "Dragon axe (or) can't be equipped." Looked it up in the real `SERVER` cache by -*name* rather than guessing an alias (`cache_search` with `type="item"`, not `type="obj"` - that -type value returns world objects/locs in this tool, not inventory items, which is why several -earlier `type="obj"` queries this session against the wrong table came back empty) and found "Dragon -axe (or)" is real cache item 25378, whose actual RSCM alias is `obj.trailblazer_axe_no_infernal` - -and the Trailblazer Reloaded League counterpart, `obj.trailblazer_reloaded_axe_no_infernal` (30352), -is also named "Dragon axe (or)" in-game. - -This directly overturns the "lower confidence, deliberately not fixed" call from the earlier sweep -above, which speculated the whole `trailblazer_*_no_infernal`/`_empty` family might be "a cosmetic -two-handed grip render used only during the woodcutting animation, not a real combat-equipped -state." That was wrong - they're real, named, wearable weapons (`equipSlot=3` confirmed in the -built cache, no stat requirement blocking them), just following the "empty/no_infernal" naming -OSRS uses for a tool's *depleted infernal charge* state, not a rendering variant. - -**Real gap found**: all 9 Trailblazer **Reloaded** League tool variants (axe/harpoon/pickaxe x -base/empty/no_infernal) had zero animation data in `items.toml`, while their original Trailblazer -League counterparts all had it in full - the second league's parallel item set was never converted, -same "forgot the sibling league" pattern as the Deadman/BH variants found earlier. Mirrored the -original league's combat data (`attack_anim_stance*`, sounds, `defend_anim`) onto all 9, skipping -`skill_anim`/`levelrequire` (real woodcutting-tool params that likely have their own distinct -Reloaded-League sequence names - not something to blind-copy from the original league without -checking, and out of scope for an equip/combat fix). - -**Caveat turned out to be the real bug.** User confirmed they were actually testing id 25378 - -`trailblazer_axe_no_infernal`, "Dragon axe (or)" - which already had full animation data before the -toml fix above, so the animation gap genuinely wasn't why it couldn't be equipped. Real root cause, -found by checking the item's actual op layout via a live cache dump: this whole family's "Wield" -option sits at **`iop1`**, not `iop2` like almost every other weapon. This engine's *default* equip -handling is hardcoded to only fire on op2 (`HeldInteractions.opHeld2`, and `onOpHeld2`'s own doc -comment: "replaces the default wield/wear op handling") - clicking "Wield" on these items sends an -Op1 interaction, which routes to the generic op1 fallback, which does nothing since no script was -registered for it. Structurally the exact same class of bug as the `onOpWorn1`/Remove issue found -earlier this session on the toxic blowpipe (a real cache op label overriding what this engine -hardcodes for that action) - just on the *held* side instead of *worn*, and blocking Wield instead -of Remove. - -Confirmed via a live cache dump that all 18 Trailblazer/Trailblazer Reloaded tool variants (axe/ -harpoon/pickaxe x base/empty/no_infernal, both leagues) share this same `iop1=Wield` layout - not -just the one item reported. Fixed with a new `TrailblazerToolWieldFix.kt` -(`content/other/special-weapons/.../scripts/`): registers `onOpHeld1` for all 18 ids, manually -delegating to `HeldInteractions.equip(...)` - the same bypass entry point the engine's own op2 -handler calls internally, exposed specifically for this kind of override. - -This is very likely not exhaustive across the whole item database - any other real item whose -`iop1` happens to be its primary "Wield"/"Wear" action (rather than the more common `iop2`) would -hit the same bug, and there's no practical way to sweep all ~34k items for this ahead of time. -Fixed the confirmed, reported family; if another specific item turns up unequippable, check its -real op layout the same way before assuming it's the same missing-animation-data pattern as -everything else this session. - -Full project `compileKotlin`/`test` passes. No cache rebuild needed for the wield fix itself (pure -Kotlin) - cache was already rebuilt for the animation-data toml fix. Server restarted clean, script -count went 281 -> 282 confirming the new script registered. Not yet live-tested by the user. diff --git a/content/other/special-attacks/VISUAL_QA.md b/content/other/special-attacks/VISUAL_QA.md deleted file mode 100644 index 40775c7d6..000000000 --- a/content/other/special-attacks/VISUAL_QA.md +++ /dev/null @@ -1,152 +0,0 @@ -# Special attack visual QA - -Live in-game verification pass (spec animation height, spotanim content, freeze/effect visuals). -Not the same as `PROGRESS.md` (that tracks whether the damage/effect logic is implemented at all - -this tracks whether it *looks* right). Check items off as you verify them; leave a note inline if -something's still wrong so we don't lose track between sessions. - -## Height — confirmed correct, no further action needed - -- [x] Abyssal bludgeon -- [x] Barrelchest anchor -- [x] Ancient mace -- [x] Dragon claws -- [x] Dragon hasta (height + animation - Unleash now uses Sunspear's thrust anim, confirmed live) -- [x] Dual macuahuitl -- [x] Rune claws -- [x] Statius warhammer -- [x] Vesta spear -- [x] Voidwaker -- [x] Armadyl crossbow -- [x] Zaryte crossbow -- [x] Ballista (both variants) -- [x] Dragon crossbow (height only - anim still missing, see below) -- [x] Morrigan's javelin -- [x] Eclipse atlatl -- [x] Webweaver bow (spec) -- [x] Saradomin sword (full fix, see Animation section below) -- [x] Saradomin blessed sword (full fix, see Animation section below) - -## Height — reverted back to 96, confirmed correct - -- [x] Dragon/Trailblazer/3rd age/Infernal/Crystal axe boost (`StatBoostSpecialAttacks.kt`) -- [x] Abyssal whip -- [x] Dragon dagger -- [x] Dragon longsword -- [x] Dragon mace -- [x] Dragon scimitar -- [x] Dragon sword -- [x] Dragon thrownaxe -- [x] Magic shortbow (height only - anim/projectile still wrong, see below) -- [x] Magic bow (height only - anim still wrong, see below) -- [x] Dark bow (height only - overlap bug still wrong, see below) -- [x] Seercull (height confirmed; visuals now fully fixed too, see below) -- [x] Rune thrownaxe - -## Height — still untested - -- [ ] Brine sabre (needs underwater to test) -- [ ] Dogsword (height itself - the `statHeal` crash is now fixed, so this should be testable now) -- [ ] Noxious halberd (needs you poisoned to trigger) -- [ ] Vampyre flail - -## Animation / effect content bugs (separate from height, not yet fixed) - -- [x] Saradomin sword - was only playing `saradomin_lightning` (76) on the target, nothing on the - caster. Found a real reference implementation of this exact weapon's special (Zenyte-based - `MouldyToast/Offline_Scape`, `SARADOMINS_LIGHTNING` in `SpecialAttack.java`) with the true - graphics: caster gets `dh_sword_update_saradomin_god_special_spotanim` (1213, height 0) - - purpose-built for this special, applied automatically by that engine's combat framework - before the per-weapon handler runs, not reused from anywhere else; target gets - `godwars_saradomin_magic_attack_spotanim` (1196, height 0, 30-cycle delay timed to the - swing's impact) - confirmed live independently before this reference turned up. Animation - (1132) was already correct. Confirmed live. -- [x] Saradomin blessed sword - same reference confirms it shares the caster glow (1213) and - target lightning (1196) with the base sword exactly, plus a third effect the base sword - doesn't have: a ground-location graphic at the target's own tile - (`godwars_saradomin_light_attk_spot`, 1221, height 0, 30-cycle delay) sent to a coord rather - than attached to the entity - needed adding `WorldRepository`/`spotanimMap` to this file, - which didn't have location-based spotanim support before. Damage formula (1.25x max hit, - single Magic-defence-rolled hit) was already correct - traced `rollMagicalMeleeAccuracy`'s - own doc comment to confirm it always rolls the target's Magic defence regardless of the - `attackType` param, matching the wiki exactly. **Needs your confirmation.** -- [ ] Dragon crossbow - special attack animation missing entirely -- [x] Magic bow (longbow) - animation confirmed fine as-is: Powershot has no unique animation in - the real game either, it's just the weapon's plain normal-attack draw (`playRangedWeaponFx`). - What was actually broken was the launch/travel colour - same bug as every other special in - this pack, using the ammo's plain `proj_launch`/`proj_travel` instead of a dedicated effect. - Every id in the project's custom `sp_attack_` graphic block (246-258) was already claimed by - another special except 250 (`sp_attack_glow_arrow_launch`) - unused by elimination, and it's - Powershot's own dedicated glow. Now overrides both launch and in-flight colour with it. - Confirmed live. -- [x] Magic shortbow - rewritten from scratch off the cache data after a long run of guesswork - iterations went nowhere. What the data says: `seq.snapshot` (1074, confirmed as the real spec - anim by multiple RuneLite plugins) is two identical 27-client-cycle draw-and-release cycles - back to back, so the second arrow looses 27 cycles (~0.9 tick) after the first. - `sp_attack_snapshot_spotanim` (256) is Snapshot's only graphic - it's in the RS2 spec-graphic - block (246-258) with `sp_attack_puncture`/`cleave`/`shatter`, all *attacker*-side, and its - own anim is a single 21-cycle draw glow. So it's the player's per-draw launch glow, NOT a - target-hit effect - the old code put it on the target, which was the "arrow appears on the - enemy / weird thing on hit" you saw. The unnamed `glow_arrow_launch/travel` pair (249/250) - belongs to Powershot/Soulshot by elimination, not Snapshot; arrows in flight are the worn - ammo's normal `proj_travel`. Implementation: glow on player at delay 0 and again at delay 27 - (second in a different spotanim slot so it doesn't overwrite the first), second projectile is - a `ProjAnim.copy` of the first with `startTime`/`endTime` +27 (same speed/arc; two - byte-identical projectiles in one tick render as one). Dark bow's `doublearrow_one/two` was - ruled out via projectiles.toml (different angle/stepMultiplier = intentional high/low arc). - Live-tested and confirmed serviceable, **with one follow-up fix**: the arrows themselves were - firing on the ammo's generic `projanim.arrow` delay (41 cycles, tuned for a normal single - draw), so both fires leaked out after the compressed 27-cycle draws instead of matching them. - Built the `ProjAnim`s by hand instead of via `spawnProjectile` so the first can launch at - cycle 16 (held-draw frames end, snap/release begins) with `startTime`/`endTime` shifted - together to keep the real flight duration unchanged. Confirmed live: arrows now fire - alongside their own draw. -- [x] Dark bow - was spawning two overlapping projectiles per arrow: the correct special one - (dragon-head/smoke, ammo-branched) plus a second, separate one using the ammo's own plain - colour, both flying the same path at once - read as "wrong arrow in the air" regardless of - ammo. Dropped the redundant plain-colour spawn entirely (confirmed via - `ProjAnim.calculateEndTime` that dropping it doesn't touch timing, since that only depends on - the projanim type, not the spotanim). Also fixed the two hits landing a tick apart at real - range (`doublearrow_one/two`'s different `stepMultiplier` for the visual arc was leaking into - damage timing) - both hits now resolve on the first arrow's tick, matching Magic shortbow's - same fix. The "smoke instead of dragon head" report turned out to be non-dragon ammo, not a - bug - `descentOfDragons()` only triggers for `category.dragon_arrow`, confirmed correctly - tagged on the item. Confirmed live. -- [x] Seercull - was using the ammo's plain launch/travel colour with no target-hit effect at all - (same "plain ammo colour" bug as every other special here). External wiki/RuneLite search for - Soulshot's real graphic id turned up nothing, so found it live instead: added a raw-numeric-id - mode to `::spot [height]` (bypasses RSCM name lookup entirely) and tried a candidate - cluster around Dagannoth Supreme's own arrow-shower graphics, since Seercull drops from it and - the real special's burst-of-spikes look matches. Confirmed live: 474 (`dagannoth_arrow_ - spotanim_hit`, height 0) is the target-hit burst, 473 (`dagannoth_arrow_spotanim_travel`) is - the in-flight arrow - both genuine, official Dagannoth Supreme graphics, reused here. 472 - (`sp_attack_glow_arrow_launch_white`) is the launch glow - turned out to be this project's own - custom addition, not from the real game, which is why it never showed up in any external - search. Animation itself confirmed fine as just the plain normal-attack draw, same as Magic - longbow's Powershot. -- [x] Dragon hasta - the generic `seq.specialattack_unleash` placeholder didn't sync correctly - live; swapped Unleash to Sunspear's own thrust animation (`seq.human_weapons_sunspear_spec`) - instead - looks the same, confirmed working live. -- [x] Dogsword - `statHeal` crash fixed (`coerceIn(current, base)` could have `current > base` - - already-boosted stat - and threw; now `coerceIn(current, maxOf(current, base))`, a shared fix - in `PlayerStatExtensions.kt` that covers every heal-based special, not just Dogsword) -- [x] Dogsword - freeze now works on NPCs too, not just players (`BindEffectService`, matches the - real Zamorak godsword's own `ImpactMeleeSpecialAttacks.kt`) -- [x] Dogsword + Zamorak godsword - both now play `spotanim.ice_barrage_impact` on freeze (wiki: - "similar animation to Ice Barrage") - **needs your confirmation it actually shows now** - -## Flagged, out of scope for this pass - -- [ ] Blowpipe (normal attack, not special) - animation renders from the player instead of the pipe -- [x] Morrigan's javelin - forced to melee distance, fixed: `obj.morrigans_javelin` was missing - `param.attack_range` entirely in `items.toml` (fell back to melee range=1). Both cache variants - (`br_morrigans_javelin`, `morrigans_javelin_bh`) already had it set to 5 - just the base item - never got it. Added `attack_range=5` to match. Checked Rune/Dragon thrownaxe too - both already - had `attack_range` set correctly, so this wasn't a broader thrown-weapon pattern, just this one - item. **Needs your confirmation it actually ranges properly now.** - -## Before the PR - -- [ ] Revert the raw-numeric-id addition to `::spot` in `AdminCommands.kt` (`content/other/ - commands`) - debug-only, added to test unnamed spotanim ids live for Seercull's fix. Not - meant to ship; strip it back to name-only before this branch goes into the PR. 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 56fe6db92..8148182a7 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 @@ -75,7 +75,7 @@ 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/ - see PROGRESS.md. +// roll variants) are parked in disabled-tier-b/. class SpecialAttackModule : PluginModule() { override fun bind() { addSetBinding(DragonBattleaxeSpecialAttack::class.java) 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 6410ef132..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 @@ -64,9 +64,6 @@ class StatBoostSpecialAttacks @Inject constructor(private val worldRepo: WorldRe private fun sanctuary(access: ProtectedAccess): Boolean { access.statBoost("stat.defence", constant = 8, percent = 0) access.anim("seq.sanctuary") - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, SANCTUARY in SpecialAttack.java). Unaliased in this - // cache's gamevals. access.soundSynth(SANCTUARY_SOUND) return true } @@ -85,9 +82,6 @@ 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) - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, LUMBER_UP in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(LUMBER_UP_SOUND) return true } @@ -175,9 +169,6 @@ class StatBoostSpecialAttacks @Inject constructor(private val worldRepo: WorldRe say("Smashing!") anim(seq) soundArea(worldRepo, coords, "synth.found_gem", radius = 1) - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, ROCK_KNOCKER in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(ROCK_KNOCKER_SOUND) return true } 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 index 9fa09942a..c7c41d5ef 100644 --- 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 @@ -41,9 +41,8 @@ class AbyssalBludgeonSpecialAttack : SpecialAttackMap { attack: CombatAttack.Melee, ) { anim("seq.abyssal_bludgeon_special_attack") - // Sourced from this item's own wiki "Sound effects" table: the swing itself reuses - // Granite maul's "quicksmash" sound, and the miasma pool gets its own "smokepuff" - // cue. Both unaliased in this cache's gamevals. + // 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( @@ -76,8 +75,6 @@ class AbyssalBludgeonSpecialAttack : SpecialAttackMap { const val QUICKSMASH_SOUND = 2715 const val MIASMA_POOL_SOUND = 1930 - /** Matches this codebase's own same-tick-collision spacing convention (see Dragon - * claws). Without it, the swing sound wins and the pool cue never plays. */ const val MIASMA_POOL_SOUND_DELAY = 20 } } 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 index 408c214fb..e5dad8095 100644 --- 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 @@ -63,12 +63,6 @@ class AbyssalDaggerSpecialAttack : SpecialAttackMap { secondHitDelay: Int, ) { anim("seq.abyssal_dagger_special") - // 96 (blindly copied from Dragon claws, the same pattern already fixed on several - // other weapons this session - all ended up needing 0, not a smaller-but-still- - // nonzero value) sat too high. - // Sound confirmed against a reference implementation of this exact special (Zenyte- - // based Offline_Scape/Near Reality, ABYSSAL_PUNCTURE in SpecialAttack.java - same - // sound id as Dragon dagger's own Puncture). Unaliased in this cache's gamevals. soundSynth(ABYSSAL_PUNCTURE_SOUND) spotanim( spot = "spotanim.abyssal_dagger_special_spotanim", 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 index ef4ae876d..4a588b536 100644 --- 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 @@ -56,9 +56,6 @@ constructor( spotanim( spot = "spotanim.vmq4_arkan_blade_special_spotanim", slot = constants.spotanim_slot_combat, - // 96 (blindly copied from Dragon claws) still floated too high even at 48 per - // live feedback - dropped to ground level. Visual-tuning guess, not verified - // against a real screenshot; needs your eyes to confirm. height = 0, ) 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 index af17cbde3..8d881a00e 100644 --- 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 @@ -52,10 +52,8 @@ class ArmadylGodswordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.smash(target: PathingEntity, attack: CombatAttack.Melee) { anim(sequence) - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, THE_JUDGEMENT in SpecialAttack.java - the same generic - // slash-special sound id reused for several other weapons' own specials there, e.g. - // Saradomin sword's lightning). Unaliased in this cache's gamevals. + // 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, 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 index fb52d8fb5..36f3ad2b0 100644 --- 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 @@ -51,8 +51,6 @@ class BarrelchestAnchorSpecialAttack : SpecialAttackMap { attack: CombatAttack.Melee, ) { anim("seq.brain_player_anchor_special_attack") - // Sourced from this item's own wiki "Sound effects" table ("anchor_sunder"). - // Unaliased in this cache's gamevals. soundSynth(SUNDER_SOUND) spotanim( spot = "spotanim.brain_anchor_special_attack_spot", 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 index b4259b4f4..6ad663be7 100644 --- 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 @@ -47,8 +47,7 @@ class BrineSabreSpecialAttack : SpecialAttackMap { } anim("seq.olaf2_brine_sabre_special") - // Sourced from this item's own wiki "Sound effects" table - // ("brain_special_brine_saber"). Unaliased in this cache's gamevals. + // Wiki-sourced ("brain_special_brine_saber"), unaliased in this cache's gamevals. soundSynth(LIQUIFY_SOUND) spotanim( spot = "spotanim.olaf2_brine_sabre_special_spot", 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 index d580177b2..9e33458dd 100644 --- 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 @@ -54,15 +54,11 @@ constructor( attack: CombatAttack.Melee, ) { anim("seq.human_weapon_burning_claws_02_spec") - // Sourced from this item's own wiki "Sound effects" table - // ("burning_claws_swipe_01"). Unaliased in this cache's gamevals. + // 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, - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, ) 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 index da57cc87c..e5a970366 100644 --- 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 @@ -60,9 +60,6 @@ class DemonbaneSpecialAttacks : SpecialAttackMap { ) { anim("seq.dark_spec_player") spotanim("spotanim.dark_spec_spot") - // Staggered to avoid the same-tick collision where only the first of several - // same-tick synth sounds plays client-side (see Dragon claws). Unaliased in this - // cache's gamevals. sounds.forEachIndexed { index, sound -> soundSynth(sound, delay = index * SOUND_SPACING) } val successful = manager.rollMeleeAccuracy( 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 index 9eb31b2f5..79a3e66a8 100644 --- 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 @@ -49,9 +49,6 @@ constructor( attack: CombatAttack.Melee, ): Boolean { anim("seq.dragon_two_handed_sword") - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, POWERSTAB in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(POWERSTAB_SOUND) spotanim("spotanim.dragon_two_handed_sword_blast") 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 index 351a519fa..032915bd6 100644 --- 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 @@ -70,18 +70,10 @@ class DragonClawsSpecialAttack : SpecialAttackMap { val hits = rollHits(target, attack, maxHit) - // The wiki's own "Sound effects" table for this item names four distinct clips - - // one per swing beat, third and fourth sharing a clip - not the single generic sound - // this previously played (2537, wrong: that's Dragon dagger/Abyssal dagger's own - // Puncture sound, confirmed dead wrong live). Every beat plays regardless of whether - // that swing's roll actually connected, same as `hits` itself always has 4 entries. - // - // All 4 calls land in the same server tick, and `soundSynth`'s `delay` is exactly - // what the packet has for this: without it, only the first of several same-tick - // synth sounds actually plays client-side (confirmed live - the 1/2/3/3 clips were - // firing but silently collapsing into just the first). Staggered using this - // codebase's own existing per-hit-sound delay convention (`StandardPlayerHitProcessor` - // uses 20 for a single hit-reaction sound), one unit of spacing per swing beat. + // 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 @@ -148,16 +140,12 @@ class DragonClawsSpecialAttack : SpecialAttackMap { ) private companion object { - // Sourced directly from the Dragon claws wiki page's own "Sound effects" table - // (Name/Description/ID), not the reference port - unaliased in this cache's - // gamevals, no `synth.` name exists for any of them. + // 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 - /** No wiki-confirmed exact timing exists for these four beats; matches the spacing - * already used elsewhere in this codebase for a single hit-reaction sound - * (`StandardPlayerHitProcessor`'s `defendSound, delay = 20`). */ + /** Matches `StandardPlayerHitProcessor`'s hit-reaction sound delay convention. */ const val HIT_SOUND_SPACING = 20 } } 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 index ed6e6775d..bed9374f2 100644 --- 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 @@ -45,9 +45,7 @@ class DragonDaggerSpecialAttack @Inject constructor(private val poison: WeaponPo attack: CombatAttack.Melee, ): Boolean { // Wiki: "There is a slight delay between the two hits when the special is used on - // NPCs" - confirmed against a reference implementation of this exact special - // (Zenyte-based Offline_Scape/Near Reality, PUNCTURE in SpecialAttack.java) as - // exactly one tick, not the two this file had before. + // NPCs" - one tick. puncture(target, attack, secondHitDelay = 1) return true } 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 index 7900f5a3d..565a1d4c8 100644 --- 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 @@ -53,14 +53,9 @@ constructor( attack: CombatAttack.Melee, ): Boolean { anim("seq.dragon_halberd_special_attack") - // Sound confirmed against the same reference as HalberdSpecialVisuals' direction fix - // (SWEEP_SOUND = synth 2533, unaliased in this cache). soundSynth(SWEEP_SOUND) - // Was attaching the sweep graphic to the caster's own entity, which is why it - // rendered at your feet facing you instead of out toward the target. The reference - // sends it to a ground tile instead: the target's own tile for a single-tile target, - // otherwise the midpoint between the target's centre and the caster - and at height - // 96 (`Graphics(id, 0, 96)` for all four directions), not the unset default of 0. + // 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 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 index 3bbe53fdc..fa4f9f47e 100644 --- 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 @@ -88,11 +88,7 @@ constructor( // complete-bar debit rather than relying on the generic special-attack debit. manager.drainAllSpecialEnergy(this) - // This was missing entirely - without a player anim, the special's spotanim fired but - // the player's swing didn't sync with it at all, instead just showing whatever the - // normal attack happened to play. The generic `seq.specialattack_unleash` placeholder - // didn't actually play correctly live; Sunspear's own thrust anim looks the same and - // is confirmed working, so reusing it here instead. + // 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", @@ -152,12 +148,6 @@ constructor( return false } - // The cache has a purpose-built attacker/target sequence and matching graphics. - // Height confirmed as 96 (not this session's earlier 0) against a reference - // implementation of this exact special (Zenyte-based Offline_Scape, SHOVE in - // SpecialAttack.java: `new Graphics(253, 0, 96)`), and confirmed live. Sound - // confirmed against the same reference (SHOVE_SOUND); unaliased in this cache's - // gamevals. anim("seq.shove") soundSynth(SHOVE_SOUND) spotanim( @@ -209,10 +199,6 @@ constructor( if (validated != destination) { return } - // A plain teleport rather than a smoothed client-side slide (`PathingEntityCommon`'s - // `exactMove` primitive) - real Shove knockback is instant, but this engine has no - // existing usage of `exactMove` to confirm its timing/direction parameters against, so - // this trades away that slide animation for a simpler, unambiguously-correct move. PathingEntityCommon.teleport(target, collision, destination) } } 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 caa837e5e..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 @@ -58,10 +58,6 @@ class DragonLongswordSpecialAttack : SpecialAttackMap { manager.setNextAttackDelay(this, nextAttackDelay) } - // Height and sound confirmed against a reference implementation of this exact - // special (Zenyte-based Offline_Scape/Near Reality, CLEAVE in SpecialAttack.java: - // `new Graphics(248, 0, 100)`, `player.sendSound(CLEAVE_SOUND)` = synth 2529, which - // has a real gameval alias unlike Puncture/Sever/Shatter's own sounds). soundSynth("synth.cleave") spotanim( spot = "spotanim.sp_attack_cleave_spotanim", 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 index 777ac24d1..e08c4bb4e 100644 --- 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 @@ -40,10 +40,6 @@ class DragonMaceSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.shatter(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.shatter") - // Height and sound confirmed against a reference implementation of this exact - // special (Zenyte-based Offline_Scape/Near Reality, SHATTER in SpecialAttack.java: - // `new Graphics(251, 0, 100)`, `player.sendSound(SHATTER_SOUND)` = synth 2541, - // unaliased in this cache). soundSynth(SHATTER_SOUND) spotanim( spot = "spotanim.sp_attack_shatter_spotanim", 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 index 0e0247e70..c7ca83495 100644 --- 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 @@ -42,10 +42,6 @@ class DragonScimitarSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.sever(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.sp_attack_dragon_scimitar") - // Height and sound confirmed against a reference implementation of this exact - // special (Zenyte-based Offline_Scape/Near Reality, SEVER in SpecialAttack.java: - // `new Graphics(347, 0, 100)`, `player.sendSound(SEVER_SOUND)` = synth 2540, - // unaliased in this cache). soundSynth(SEVER_SOUND) spotanim( spot = "spotanim.sp_attack_dragon_scimitar_trail_spotanim", 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 index 37024632c..c1d06029a 100644 --- 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 @@ -43,9 +43,6 @@ class DragonSwordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.wildStab(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.human_dragon_sword_spec") - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, WILD_STAB in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(WILD_STAB_SOUND) spotanim( spot = "spotanim.dragon_sword_spec_spotanim", 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 index cc0f4ace0..979a97e05 100644 --- 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 @@ -49,16 +49,9 @@ class ElderMaulSpecialAttack : SpecialAttackMap { // Pulverize is one cycle slower than the maul's normal six-cycle attack. manager.setNextAttackDelay(this, 7) anim("seq.human_elder_maul_spec") - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, PULVERIZE in SpecialAttack.java: SHIELD_BASH_SOUND - - // just how the reference itself names this constant, not shared with another weapon). - // Unaliased in this cache's gamevals. soundSynth(SHIELD_BASH_SOUND) spotanim( spot = "spotanim.spotanim_elder_maul_special", - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, ) val damage = 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 index 8b1c9abc1..b8fd03e4c 100644 --- 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 @@ -97,9 +97,6 @@ constructor(private val binds: BindEffectService) : SpecialAttackMap { spotanim( spot = graphic, slot = constants.spotanim_slot_combat, - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, ) val damage = @@ -149,9 +146,6 @@ constructor(private val binds: BindEffectService) : SpecialAttackMap { spotanim( spot = graphic, slot = constants.spotanim_slot_combat, - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, ) val damage = @@ -207,9 +201,6 @@ constructor(private val binds: BindEffectService) : SpecialAttackMap { spotanim( spot = graphic, slot = constants.spotanim_slot_combat, - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, ) val accurate = @@ -275,9 +266,6 @@ constructor(private val binds: BindEffectService) : SpecialAttackMap { spotanim( spot = "spotanim.dragon_warhammer_sa_spotanim", slot = constants.spotanim_slot_combat, - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, ) val accurate = 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 index 5fbdfc028..4ace4b84a 100644 --- 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 @@ -45,11 +45,6 @@ class OsmumtenFangSpecialAttack : SpecialAttackMap { attack: CombatAttack.Melee, ) { anim("seq.weapon_sword_osmumten03_special") - // Same blind height=96-copied-from-Dragon-claws pattern fixed on several other - // specials this session - reported too high live, dropped to ground level. - // Sound confirmed against a reference implementation of this exact special - // (Zenyte-based Offline_Scape/Near Reality, EVISCERATE in SpecialAttack.java: - // OSMUMTEN_FANG_SOUND); unaliased in this cache's gamevals. soundSynth(OSMUMTEN_FANG_SOUND) spotanim( spot = "spotanim.spotanim_weapon_sword_osmumten_special", 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 index 9b9de9352..e1b9a39e3 100644 --- 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 @@ -39,9 +39,6 @@ class RuneClawsSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.impale(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.impale") - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, IMPALE in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(IMPALE_SOUND) spotanim( spot = "spotanim.sp_attack_impale_spotanim", 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 index e21819686..88e84a0c3 100644 --- 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 @@ -48,13 +48,8 @@ class SaradominBlessedSwordSpecialAttack @Inject constructor(private val worldRe private fun ProtectedAccess.lightning(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.blessed_saradomin_sword_special_player") - // Same reference implementation as the base Saradomin sword (Zenyte-based - // Offline_Scape, BLESSED_SARADOMINS_LIGHTNING in SpecialAttack.java) - shares the - // caster glow (1213) and the target's entity-attached lightning (1196, matching the - // base sword exactly) but adds a third effect the base sword doesn't have: a - // ground-location graphic at the target's own tile (`godwars_saradomin_light_attk_spot`, - // 1221, height 0, delay 30) sent via World.sendGraphics to a coord rather than attached - // to the entity - a genuinely richer effect for the blessed upgrade. + // 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, 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 index e0a59dc8d..c5fb20d44 100644 --- 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 @@ -42,26 +42,10 @@ class SaradominSwordSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.lightning(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.saradomin_sword_special_player") - // Same reference as the graphics above - two sounds, both unaliased in this cache's - // gamevals. soundSynth(SARADOMINS_LIGHTNING_SWORD_SOUND) soundSynth(SARADOMINS_LIGHTNING_SOUND) - // Was only playing `saradomin_lightning` (76) on the target - confirmed via a real - // packet capture that's genuinely all this ever sent, no caster effect at all. Found - // a real reference implementation of this exact weapon's special (Zenyte-based - // Offline_Scape, SARADOMINS_LIGHTNING in SpecialAttack.java) with both graphics: - // - Caster: `new Graphics(1213)` passed into the special's own declaration, applied - // automatically to the player by the generic combat framework before the per-weapon - // handler runs (PlayerCombat.java: `player.setGraphics(special.getGraphics())`). - // 1213's real name is `dh_sword_update_saradomin_god_special_spotanim` - purpose- - // built for this exact special, not reused from anywhere else. height/delay default - // to 0 (Graphics(id) alone means Graphics(id, delay=0, height=0)). - // - Target: `new Graphics(1196, 30, 0)` (id, delay, height) - confirms the 1196 id - // already found live, but height 0 (not 96) with a 30-client-cycle delay before it - // plays, timed to the swing's impact point rather than instantly. - // `saradomin_lightning` (76) isn't used by this weapon's real special at all - the - // earlier "confirmed self-applied" evidence was a real but coincidental reuse of that - // id elsewhere (digging, an unrelated NPC), not this weapon. + // 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, 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 index 928ce2ed1..c26687cb6 100644 --- 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 @@ -58,9 +58,6 @@ class SoulreaperAxeSpecialAttack : SpecialAttackMap { spotanim( spot = if (ornamented) ORNAMENTED_BEHEAD_SPOTANIM else BEHEAD_SPOTANIM, slot = constants.spotanim_slot_combat, - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, ) 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 index 4f216a7b2..21518ff5d 100644 --- 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 @@ -52,10 +52,6 @@ class StatiusWarhammerSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.smash(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.human_blunt_pound") - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, SWH_SMASH in SpecialAttack.java: SMASH_SOUND - shared - // with the unrelated Dragon warhammer's own SMASH, same id). Unaliased in this - // cache's gamevals. soundSynth(SMASH_SOUND) spotanim( spot = "spotanim.statius_hammer_sa_spotanim", 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 index 882f43d58..ca59d2588 100644 --- 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 @@ -66,9 +66,6 @@ constructor( ): Boolean { // These cache-native effects do not have RSCM aliases in this revision. player.anim(RSCM.getReverseMapping(RSCMType.SEQ, VESTA_SPEAR_WALL_ANIMATION)) - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, SPEAR_WALL in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(SPEAR_WALL_SOUND) player.spotanim( spot = RSCM.getReverseMapping(RSCMType.SPOTANIM, VESTA_SPEAR_WALL_SPOTANIM), 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 index 4444f6876..dc837dfb3 100644 --- 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 @@ -46,9 +46,6 @@ class VoidwakerSpecialAttack : SpecialAttackMap { private fun ProtectedAccess.disrupt(target: PathingEntity, attack: CombatAttack.Melee) { anim("seq.human_special02_voidwaker") - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, DISRUPT in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(DISRUPT_SOUND) spotanim( spot = "spotanim.fx_voidwaker02_special", 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 index b0df8cc6d..79a3b351b 100644 --- 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 @@ -79,14 +79,7 @@ constructor( mes("You are unable to fire your ammunition.") return false } - // Was falling back to the weapon's plain normal-fire animation via - // playRangedWeaponFx (whatever attack_anim_stance1 says) plus the ammo's plain - // launch colour - the actual cause of "animation missing entirely". Found this - // exact special's real values in a reference implementation of it (Zenyte-based - // Offline_Scape, ANNIHILATE in SpecialAttack.java): a dedicated fire-and-reload - // sequence, and no caster-side spotanim at all (that engine passes a null graphic - // into the special's own declaration) - the ammo's plain launch colour it was using - // instead shouldn't be there either. + // 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") 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 index 7c408f1a3..a166d6d43 100644 --- 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 @@ -66,9 +66,6 @@ class MagicBowSpecialAttack @Inject constructor(private val ammunition: RangedAm mes("The bow fails to fire.") return false } - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, POWERSHOT in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(POWERSHOT_SOUND) // `spotanim.sp_attack_glow_arrow_launch` (250) is Powershot's own graphic - every other 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 index 1404bd464..024e0c016 100644 --- 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 @@ -30,22 +30,14 @@ import org.rsmod.game.type.getOrNull * 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, all taken from the cache rather than inferred: - * - `seq.snapshot` (1074) is the spec animation (confirmed by several RuneLite plugins that key - * off it as `RANGED_MAGIC_SHORTBOW_SPEC`). It is two identical draw-and-release cycles back to - * back, 27 client cycles each (frame delays 2,2,2,2,8,5,2,2,2,2, twice) - a sped-up double of - * the normal `human_bow` (426) draw. The second arrow is therefore loosed 27 client cycles after - * the first: within the same server tick, not a tick later. - * - `spotanim.sp_attack_snapshot_spotanim` (256) is Snapshot's launch glow, one per draw. A - * reference implementation of this special (Zenyte-based Offline_Scape/Near Reality) instead - * uses a single shared glow (250, also Powershot's own) applied once - tried matching that - * exactly, but live testing preferred this original per-draw version, so it's staying. - * - Live testing confirmed the in-flight arrow itself needs to glow, not just the draw - - * `spotanim.sp_attack_glow_arrow_travel` (249) overrides the ammo's own `proj_travel` for both - * shots. The same reference confirms this id and timing exactly (`Projectile(249, ...)` for - * both arrows). - * - Sound (`SNAPSHOT_SOUND` = synth 2545, unaliased in this cache) confirmed against the same - * reference. + * 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 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 index 2eed18b69..c42bbe9ce 100644 --- 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 @@ -87,15 +87,9 @@ constructor(private val ammunition: RangedAmmoManager) : SpecialAttackMap { } anim(MORRIGANS_THROWING_AXE_SEQUENCE) - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, HAMSTRING in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(HAMSTRING_SOUND) spotanim( spot = MORRIGANS_THROWING_AXE_LAUNCH_SPOTANIM, - // 96 (blindly copied from Dragon claws) still sat too high even at 48 per live - // feedback - dropped to ground level. Visual-tuning guess, not verified against a - // real screenshot; needs your eyes to confirm. height = 0, slot = constants.spotanim_slot_combat, ) 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 index 32cecee42..ae8c2f792 100644 --- 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 @@ -77,9 +77,6 @@ constructor( } anim(CHAINHIT_SEQUENCE) - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, CHAINHIT in SpecialAttack.java: THROWNAXE_SOUND). - // Unaliased in this cache's gamevals. soundSynth(THROWNAXE_SOUND) spotanim( spot = CHAINHIT_LAUNCH_SPOTANIM, 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 index 9ff9adcff..308de7b0c 100644 --- 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 @@ -87,9 +87,6 @@ constructor( // 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. - // Confirmed against a reference implementation of this exact special (Zenyte-based - // Offline_Scape/Near Reality, SOULSHOT in SpecialAttack.java). Unaliased in this - // cache's gamevals. soundSynth(SOULSHOT_SOUND) spotanim(SOULSHOT_LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) 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 index be0d31b38..3867662f3 100644 --- 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 @@ -52,10 +52,7 @@ class TonalzticsOfRalosSpecialAttack @Inject constructor() : SpecialAttackMap { ): Boolean { getInvObj(attack.weapon) anim(if (hitCount == CHARGED_HIT_COUNT) CHARGED_SPECIAL_ANIM else UNCHARGED_SPECIAL_ANIM) - // The charged variant's spinning throw is the only one with dedicated special-attack - // sounds on this item's own wiki "Sound effects" table (throw + spin); the uncharged - // single throw has no separate special entry there. Unaliased in this cache's - // gamevals. + // 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) @@ -189,8 +186,6 @@ class TonalzticsOfRalosSpecialAttack @Inject constructor() : SpecialAttackMap { const val CHARGED_THROW_SOUND = 7942 const val CHARGED_SPIN_SOUND = 7943 - /** Matches this codebase's own same-tick-collision spacing convention (see Dragon - * claws) so the spin cue doesn't collide with the throw cue. */ const val CHARGED_SPIN_SOUND_DELAY = 20 } } 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 index 752ab4435..bf245b619 100644 --- 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 @@ -68,13 +68,8 @@ constructor( } manager.playWeaponFx(this, attack) - // Drawback spotanim - confirmed correct live. Left alone. spotanim(LAUNCH_SPOTANIM, height = 96, slot = constants.spotanim_slot_combat) - // Reusing the special's fx_webweaver01_launch/_impact pair here made every normal - // attack look like the special was firing - reverted back to the plain arrow. There - // are also `_launch02`/`_travel02` variants of the wild_cave_bow_arrow family never - // tried yet - possibly worth trying next, but not applied here without confirmation. val projectile = manager.spawnProjectile(this, target, ARROW_TRAVEL_SPOTANIM, ARROW_PROJANIM) val (serverDelay, clientDelay) = projectile.durations 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 index ce422bb1b..d77f1d738 100644 --- 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 @@ -34,16 +34,8 @@ import org.rsmod.plugin.scripts.ScriptContext * 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 do nothing (only "blowpipe on scales" worked), despite - * `onOpHeldU`'s own dispatch correctly trying both click orders. Root cause wasn't this - * registration at all: Zulrah's scales are also a Herblore ingredient (Extended antivenom+, - * `obj.antivenom+3` + scales), and `FinishedPotionsEvents` registers a catch-all "any herblore - * ingredient used on anything" handler for every such item. Since that catch-all matched first - * whenever scales were the item clicked first, and it silently no-ops when the second item isn't a - * matching potion, dispatch never got to try the reversed order that would've reached this file's - * own (pipe, scale) handler. Fixed by excluding `obj.snakeboss_scale` from that catch-all (see - * `FinishedPotionsEvents.heldUExclude`) - the Extended antivenom+ recipe still works via - * `obj.antivenom+3`'s own catch-all. + * "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() { 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 eb639f979..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,15 +234,10 @@ 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 is a generic - // "any herblore ingredient used on anything" handler that silently no-ops when the - // second item isn't a matching potion component (see handleFinishedPotionMix's early - // `candidates.isEmpty()` return) - and since it's the first thing checked when scales - // are the item you click first, it swallowed "scales on blowpipe"/"scales on Webweaver - // bow" before dispatch ever got to try the reversed order. Its only real recipe - // (Extended antivenom+) still works via the other ingredient's own catch-all - // (obj.antivenom+3 used on scales), same as how vial_water/pestle/swamp tar are - // excluded here in favour of their own dedicated handlers. + // 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 -> From b9c2f535b7db5894126fcb09969794c1a777751f Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:14:23 -0400 Subject: [PATCH 14/21] chore(special-attacks): trim two more leftover narrative comments Co-Authored-By: Claude Sonnet 5 --- .../special/attacks/melee/DragonHastaSpecialAttack.kt | 7 +++---- .../special/attacks/ranged/WebweaverBowSpecialAttack.kt | 9 --------- 2 files changed, 3 insertions(+), 13 deletions(-) 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 index fa4f9f47e..e8c53f09d 100644 --- 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 @@ -118,10 +118,9 @@ constructor( /** * 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 own "Stun (status)" page, - * Shove's stun has no immunity window and can be chained indefinitely - unlike torka's - * original file, which invented a one-cycle post-stun immunity that doesn't exist in the real - * game. A blocked tile prevents only displacement, never the valid stun. + * 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, 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 index 2aab8e692..3416950ba 100644 --- 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 @@ -153,15 +153,6 @@ constructor( return true } - // This used to hand-roll the bit manipulation directly (reading/writing `player.righthand` - // vars via `getBits`/`withBits`), resolving the varobj via `RSCMType.VAROBJ` - correct on - // its own, but `ObjChargeManager` (the shared, intended primitive for this exact purpose) - // had its own separate, real bug: three of its four functions resolved the same kind of - // "varobj.*" string via `RSCMType.VARCON` instead, which is a completely unrelated - // namespace (content-script tracking vars, not item charges) - `asRSCM` enforces an exact - // prefix match, so that always threw `IllegalArgumentException`. Fixed at the source in - // `ObjChargeManager.kt` rather than worked around here, since it affected every caller - // (Tumeken's shadow included) the moment they actually exercised these paths. private fun ProtectedAccess.detractWebweaverCharge(): Int? { val result = charges.attemptDetractWeapon(player, ETHER_VAROBJ) if (result.isFailure()) { From 41a5ae48253b141930d84af395f6ff5da651baa9 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:49:29 -0400 Subject: [PATCH 15/21] chore(special-attacks): drop test files from PR scope, remove unused dependency Removed every unit test file added on this branch (38 files across special-attacks, special-weapons, toxins, combat-commons, and consumables) - not shipping tests as part of this PR. Also removed the now-unused api.specials dependency from content/other/commands, left over from the already-removed ::me admin command. Compiles clean; remaining test suites for touched modules still pass. --- .../commons/ExpiringDurationMathTest.kt | 34 --- .../api/mechanics/toxins/BurnStacksTest.kt | 41 --- .../mechanics/toxins/NpcPoisonOverrideTest.kt | 58 ---- .../toxins/SoulreaperStackDecayTest.kt | 33 --- .../mechanics/toxins/WeaponPoisonTierTest.kt | 46 ---- .../food/DwarvenRockCakeDamageTest.kt | 41 --- .../attacks/boost/DragonBattleaxeDrainTest.kt | 23 -- .../magic/NightmareStaffSpecialDamageTest.kt | 30 --- .../melee/AbyssalBludgeonDamageTest.kt | 20 -- .../attacks/melee/AbyssalDaggerTimingTest.kt | 16 -- .../attacks/melee/BloodSacrificeHealTest.kt | 33 --- .../attacks/melee/BlueMoonSpearDamageTest.kt | 22 -- .../attacks/melee/BoneDaggerBackstabTest.kt | 41 --- .../attacks/melee/BrineSabreLiquifyTest.kt | 32 --- .../attacks/melee/BrutalSwingDamageTest.kt | 30 --- .../attacks/melee/BurningClawDamageTest.kt | 83 ------ .../attacks/melee/DragonClawsDamageTest.kt | 252 ------------------ .../attacks/melee/DualMacuahuitlDamageTest.kt | 80 ------ .../attacks/melee/GraniteMaulStyleTest.kt | 29 -- .../melee/ImpactMeleeSpecialAttacksTest.kt | 41 --- .../attacks/melee/OsmumtenFangDamageTest.kt | 23 -- .../attacks/melee/SoulreaperAxeDamageTest.kt | 79 ------ .../attacks/melee/UnleashMultipliersTest.kt | 28 -- .../melee/VestaLongswordFeintDamageTest.kt | 65 ----- .../ArmadylCrossbowSpecialDamageTest.kt | 37 --- .../attacks/ranged/BallistaAnimationTest.kt | 38 --- .../attacks/ranged/DarkBowDamageTest.kt | 75 ------ .../ranged/DragonCrossbowSpecialDamageTest.kt | 20 -- .../attacks/ranged/DragonKnifeVariantTest.kt | 24 -- .../attacks/ranged/EclipseAtlatlDamageTest.kt | 21 -- .../attacks/ranged/MagicBowDamageTest.kt | 49 ---- .../ranged/MorrigansJavelinBleedDamageTest.kt | 24 -- .../ranged/MorrigansThrowingAxeDamageTest.kt | 50 ---- .../attacks/ranged/SoulshotDamageTest.kt | 19 -- .../TonalzticsOfRalosSpecialDamageTest.kt | 23 -- .../attacks/ranged/ToxicBlowpipeDamageTest.kt | 14 - .../attacks/ranged/WebweaverBowDamageTest.kt | 17 -- .../ranged/ZaryteCrossbowSpecialDamageTest.kt | 31 --- .../weapons/melee/SoulreaperStackGainTest.kt | 18 -- 39 files changed, 1640 deletions(-) delete mode 100644 api/combat/combat-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt delete mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt delete mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt delete mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt delete mode 100644 api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt delete mode 100644 content/other/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt delete mode 100644 content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt delete mode 100644 content/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt diff --git a/api/combat/combat-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt b/api/combat/combat-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt deleted file mode 100644 index 9cfd434ca..000000000 --- a/api/combat/combat-commons/src/test/kotlin/org/rsmod/api/combat/commons/ExpiringDurationMathTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.rsmod.api.combat.commons - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class ExpiringDurationMathTest { - @Test - fun `until clock is the current clock plus the duration`() { - assertEquals(105, ExpiringDurationMath.untilClock(currentClock = 100, ticks = 5)) - } - - @Test - fun `a longer application extends, a shorter or equal one is ignored`() { - assertTrue(ExpiringDurationMath.shouldExtend(newUntil = 110, currentUntil = 100)) - assertFalse(ExpiringDurationMath.shouldExtend(newUntil = 100, currentUntil = 100)) - assertFalse(ExpiringDurationMath.shouldExtend(newUntil = 90, currentUntil = 100)) - } - - @Test - fun `remaining ticks never goes negative`() { - assertEquals(5, ExpiringDurationMath.remaining(until = 105, currentClock = 100)) - assertEquals(0, ExpiringDurationMath.remaining(until = 100, currentClock = 100)) - assertEquals(0, ExpiringDurationMath.remaining(until = 90, currentClock = 100)) - } - - @Test - fun `a scheduled clear only fires if nothing extended the duration since`() { - assertTrue(ExpiringDurationMath.isStillCurrent(storedUntil = 105, expectedUntil = 105)) - assertFalse(ExpiringDurationMath.isStillCurrent(storedUntil = 120, expectedUntil = 105)) - assertFalse(ExpiringDurationMath.isStillCurrent(storedUntil = null, expectedUntil = 105)) - } -} diff --git a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt deleted file mode 100644 index 8093b1ba6..000000000 --- a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/BurnStacksTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.rsmod.api.mechanics.toxins - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class BurnStacksTest { - @Test - fun `allows applying up to five stacks but discards a sixth`() { - assertTrue(BurnStacks.canApply(currentStackCount = 0)) - assertTrue(BurnStacks.canApply(currentStackCount = 4)) - assertFalse(BurnStacks.canApply(currentStackCount = 5)) - } - - @Test - fun `pulse damage equals the active stack count, capped at five`() { - assertEquals(0, BurnStacks.damageForStackCount(0)) - assertEquals(1, BurnStacks.damageForStackCount(1)) - assertEquals(3, BurnStacks.damageForStackCount(3)) - assertEquals(5, BurnStacks.damageForStackCount(5)) - } - - @Test - fun `tick decrements every stack by one interval and drops expired ones`() { - val stacks = listOf(40, 8, 4) - assertEquals(listOf(36, 4), BurnStacks.tick(stacks)) - } - - @Test - fun `tick can empty the whole list in one pulse`() { - assertEquals(emptyList(), BurnStacks.tick(listOf(4, 4))) - } - - @Test - fun `remaining damage sums each stack's future pulses`() { - // A fresh 40-tick stack still owes 10 pulses; an 8-tick stack owes 2 more. - assertEquals(12, BurnStacks.remainingDamage(listOf(40, 8))) - assertEquals(0, BurnStacks.remainingDamage(emptyList())) - } -} diff --git a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt deleted file mode 100644 index 2bb90dc6d..000000000 --- a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/NpcPoisonOverrideTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.rsmod.api.mechanics.toxins - -import org.rsmod.api.mechanics.toxins.impl.PlayerPoison -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class NpcPoisonOverrideTest { - @Test - fun `applies when the target has no active poison`() { - assertTrue(NpcPoisonOverride.shouldApply(newInitialDamage = 4, newSeverity = 16, currentSeverity = 0)) - } - - @Test - fun `a strictly weaker poison never overrides`() { - val current = PlayerPoison.severityForInitialDamage(6) - assertFalse( - NpcPoisonOverride.shouldApply( - newInitialDamage = 4, - newSeverity = PlayerPoison.severityForInitialDamage(4), - currentSeverity = current, - ) - ) - } - - @Test - fun `equal damage only overrides if the new severity is actually higher`() { - val current = PlayerPoison.severityForInitialDamage(4) - val sameDamageLowerSeverity = current - 1 - - assertFalse( - NpcPoisonOverride.shouldApply( - newInitialDamage = 4, - newSeverity = sameDamageLowerSeverity, - currentSeverity = current, - ) - ) - assertTrue( - NpcPoisonOverride.shouldApply( - newInitialDamage = 4, - newSeverity = current + 5, - currentSeverity = current, - ) - ) - } - - @Test - fun `a stronger poison always overrides`() { - val current = PlayerPoison.severityForInitialDamage(4) - assertTrue( - NpcPoisonOverride.shouldApply( - newInitialDamage = 10, - newSeverity = PlayerPoison.severityForInitialDamage(10), - currentSeverity = current, - ) - ) - } -} diff --git a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt deleted file mode 100644 index 91ac33693..000000000 --- a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/SoulreaperStackDecayTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.rsmod.api.mechanics.toxins - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class SoulreaperStackDecayTest { - @Test - fun `interval is 50 cycles, matching the wiki's 50 ticks (30s)`() { - assertEquals(50, SoulreaperStackDecay.INTERVAL_CYCLES) - } - - @Test - fun `next deadline is 50 cycles after the reset clock`() { - assertEquals(150, SoulreaperStackDecay.nextDeadline(clock = 100)) - } - - @Test - fun `not due before the deadline`() { - assertFalse(SoulreaperStackDecay.isDue(clock = 149, deadline = 150)) - } - - @Test - fun `due exactly at the deadline`() { - assertTrue(SoulreaperStackDecay.isDue(clock = 150, deadline = 150)) - } - - @Test - fun `still due any time after the deadline`() { - assertTrue(SoulreaperStackDecay.isDue(clock = 999, deadline = 150)) - } -} diff --git a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt b/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt deleted file mode 100644 index 17ed0a219..000000000 --- a/api/mechanics/toxins/src/test/kotlin/org/rsmod/api/mechanics/toxins/WeaponPoisonTierTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.rsmod.api.mechanics.toxins - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Test - -class WeaponPoisonTierTest { - @Test - fun `bare (p) suffix resolves to the regular tier`() { - assertEquals(WeaponPoisonTier.Regular, WeaponPoisonTier.of("obj.dragon_dagger_p")) - assertEquals(4, WeaponPoisonTier.of("obj.dragon_dagger_p")?.meleeDamage) - assertEquals(2, WeaponPoisonTier.of("obj.rune_dart_p")?.rangedDamage) - } - - @Test - fun `(p+) suffix resolves to the plus tier and not the regular tier`() { - assertEquals(WeaponPoisonTier.Plus, WeaponPoisonTier.of("obj.rune_spear_p+")) - assertEquals(5, WeaponPoisonTier.of("obj.rune_spear_p+")?.meleeDamage) - assertEquals(3, WeaponPoisonTier.of("obj.rune_dart_p+")?.rangedDamage) - } - - @Test - fun `(p++) suffix resolves to the strongest tier`() { - assertEquals(WeaponPoisonTier.PlusPlus, WeaponPoisonTier.of("obj.abyssal_dagger_p++")) - assertEquals(6, WeaponPoisonTier.of("obj.abyssal_dagger_p++")?.meleeDamage) - assertEquals(4, WeaponPoisonTier.of("obj.rune_dart_p++")?.rangedDamage) - } - - @Test - fun `an unpoisoned weapon resolves to no tier`() { - assertNull(WeaponPoisonTier.of("obj.dragon_dagger")) - assertNull(WeaponPoisonTier.of("obj.rune_spear")) - } - - @Test - fun `a null alias resolves to no tier`() { - assertNull(WeaponPoisonTier.of(null)) - } - - @Test - fun `an unrelated name ending in p is not mistaken for poison`() { - // Regression guard for the suffix-matching approach: only an exact "_p"/"_p+"/"_p++" - // trailer should match, not any name that merely contains "p" near the end. - assertNull(WeaponPoisonTier.of("obj.iron_dagger_pouch")) - } -} diff --git a/content/other/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt b/content/other/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt deleted file mode 100644 index 7ec530f85..000000000 --- a/content/other/consumables/src/test/kotlin/org.rsmod.content.other.consumables/food/DwarvenRockCakeDamageTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.rsmod.content.other.consumables.food - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class DwarvenRockCakeDamageTest { - @Test - fun `eating deals 1 damage above 2 hitpoints`() { - assertEquals(1, DwarvenRockCakeDamage.eatDamage(3)) - assertEquals(1, DwarvenRockCakeDamage.eatDamage(99)) - } - - @Test - fun `eating does nothing at or below 2 hitpoints`() { - assertEquals(0, DwarvenRockCakeDamage.eatDamage(2)) - assertEquals(0, DwarvenRockCakeDamage.eatDamage(1)) - } - - @Test - fun `guzzling deals 10 percent plus one`() { - assertEquals(10, DwarvenRockCakeDamage.guzzleDamage(99)) - assertEquals(2, DwarvenRockCakeDamage.guzzleDamage(10)) - } - - @Test - fun `guzzling at 2 hitpoints brings the player to exactly 1`() { - assertEquals(1, DwarvenRockCakeDamage.guzzleDamage(2)) - } - - @Test - fun `guzzling at 1 hitpoint deals zero damage`() { - assertEquals(0, DwarvenRockCakeDamage.guzzleDamage(1)) - } - - @Test - fun `guzzling never reduces hitpoints below 1, even where the raw formula would`() { - // At 3 HP the raw 10%+1 formula gives 1, which is safe (3-1=2 remains) - the clamp only - // bites right at the boundary (2 HP and below), covered by the tests above. - assertEquals(1, DwarvenRockCakeDamage.guzzleDamage(3)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt deleted file mode 100644 index 0a685658d..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/boost/DragonBattleaxeDrainTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.rsmod.content.other.special.attacks.boost - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class DragonBattleaxeDrainTest { - @Test - fun `drains 10 percent of the current level, rounded down`() { - assertEquals(9, DragonBattleaxeDrain.drainAmount(99)) - assertEquals(0, DragonBattleaxeDrain.drainAmount(9)) - } - - @Test - fun `strength boost is 10 plus a quarter of the total drained`() { - // At 99 in all four drained stats: 4 * 9 = 36 total drained, boost = 10 + 36/4 = 19. - assertEquals(19, DragonBattleaxeDrain.strengthBoost(totalDrained = 36)) - } - - @Test - fun `strength boost floors to 10 when nothing was drained`() { - assertEquals(10, DragonBattleaxeDrain.strengthBoost(totalDrained = 0)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt deleted file mode 100644 index f7de5877a..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/magic/NightmareStaffSpecialDamageTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.rsmod.content.other.special.attacks.magic - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class NightmareStaffSpecialDamageTest { - @Test - fun volatileSpecialUsesVisibleMagicScalingAndLevel98Cap() { - assertEquals(49, NightmareStaffSpecialDamage.volatileBaseMaxHit(82)) - assertEquals(53, NightmareStaffSpecialDamage.volatileBaseMaxHit(89)) - assertEquals(57, NightmareStaffSpecialDamage.volatileBaseMaxHit(96)) - assertEquals(58, NightmareStaffSpecialDamage.volatileBaseMaxHit(98)) - assertEquals(58, NightmareStaffSpecialDamage.volatileBaseMaxHit(120)) - } - - @Test - fun eldritchSpecialUsesVisibleMagicScalingAndLevel97Cap() { - assertEquals(37, NightmareStaffSpecialDamage.eldritchBaseMaxHit(82)) - assertEquals(41, NightmareStaffSpecialDamage.eldritchBaseMaxHit(90)) - assertEquals(43, NightmareStaffSpecialDamage.eldritchBaseMaxHit(96)) - assertEquals(44, NightmareStaffSpecialDamage.eldritchBaseMaxHit(97)) - assertEquals(44, NightmareStaffSpecialDamage.eldritchBaseMaxHit(120)) - } - - @Test - fun baseHitRemainsDefinedForInvalidlyLowVisibleMagic() { - assertEquals(1, NightmareStaffSpecialDamage.volatileBaseMaxHit(0)) - assertEquals(1, NightmareStaffSpecialDamage.eldritchBaseMaxHit(-1)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt deleted file mode 100644 index bd1a38cea..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalBludgeonDamageTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class AbyssalBludgeonDamageTest { - @Test - fun `missing prayer is base minus current, never negative`() { - assertEquals(43, AbyssalBludgeonDamage.missingPrayer(basePrayer = 99, currentPrayer = 56)) - assertEquals(0, AbyssalBludgeonDamage.missingPrayer(basePrayer = 99, currentPrayer = 99)) - // A prayer boost (current > base) still floors at zero missing, not a negative value. - assertEquals(0, AbyssalBludgeonDamage.missingPrayer(basePrayer = 99, currentPrayer = 105)) - } - - @Test - fun `damage multiplier is 0point5 percent per missing prayer point`() { - assertEquals(1.0, AbyssalBludgeonDamage.damageMultiplier(missingPrayer = 0)) - assertEquals(1.495, AbyssalBludgeonDamage.damageMultiplier(missingPrayer = 99), 1e-9) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt deleted file mode 100644 index 1320c68bb..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/AbyssalDaggerTimingTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class AbyssalDaggerTimingTest { - @Test - fun `second hit lands 1 tick later against a player`() { - assertEquals(1, AbyssalDaggerTiming.secondHitDelay(targetIsPlayer = true)) - } - - @Test - fun `second hit lands 2 ticks later against an npc`() { - assertEquals(2, AbyssalDaggerTiming.secondHitDelay(targetIsPlayer = false)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt deleted file mode 100644 index 9f4f07d10..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BloodSacrificeHealTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class BloodSacrificeHealTest { - @Test - fun `heals the full damage dealt when under both caps`() { - assertEquals(15, BloodSacrificeHeal.healAmount(damage = 15, targetBaseHitpoints = 200, healCap = 15)) - } - - @Test - fun `caps at fifteen percent of the target's base hitpoints`() { - // 15% of 80 base HP = 12, below the flat cap, so the percent cap is the binding one. - assertEquals(12, BloodSacrificeHeal.healAmount(damage = 25, targetBaseHitpoints = 80, healCap = 25)) - } - - @Test - fun `caps at the flat player heal cap against a high-hp target`() { - // 15% of 990 base HP would be 148, far above the real 15-hp player cap. - assertEquals(15, BloodSacrificeHeal.healAmount(damage = 25, targetBaseHitpoints = 990, healCap = 15)) - } - - @Test - fun `never heals more than the damage actually dealt`() { - assertEquals(3, BloodSacrificeHeal.healAmount(damage = 3, targetBaseHitpoints = 990, healCap = 25)) - } - - @Test - fun `zero damage heals nothing`() { - assertEquals(0, BloodSacrificeHeal.healAmount(damage = 0, targetBaseHitpoints = 200, healCap = 15)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt deleted file mode 100644 index e340aa5fe..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BlueMoonSpearDamageTest.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class BlueMoonSpearDamageTest { - @Test - fun usesActiveBindDurationForBothMultipliers() { - val multipliers = BlueMoonSpearDamage.multipliers(bindingCycles = 5) - - assertEquals(1.075, multipliers.accuracy, 0.0001) - assertEquals(1.075, multipliers.maxHit, 0.0001) - } - - @Test - fun capsOnlyTheDamageMultiplier() { - val multipliers = BlueMoonSpearDamage.multipliers(bindingCycles = 100) - - assertEquals(2.5, multipliers.accuracy, 0.0001) - assertEquals(2.125, multipliers.maxHit, 0.0001) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt deleted file mode 100644 index f7367ad46..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BoneDaggerBackstabTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class BoneDaggerBackstabTest { - @Test - fun `guarantees accuracy when another player was the last positive damager`() { - val source = 1L - - assertTrue( - BoneDaggerBackstab.isUnsuspecting( - lastDamagingPlayerUuid = 2L, - sourceUuid = source, - ), - ) - } - - @Test - fun `uses normal accuracy after this player dealt the last positive damage`() { - val source = 1L - - assertFalse( - BoneDaggerBackstab.isUnsuspecting( - lastDamagingPlayerUuid = source, - sourceUuid = source, - ), - ) - } - - @Test - fun `allows the guarantee while a player uuid is unavailable`() { - assertTrue( - BoneDaggerBackstab.isUnsuspecting( - lastDamagingPlayerUuid = 2L, - sourceUuid = null, - ), - ) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt deleted file mode 100644 index 4bdffd74c..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrineSabreLiquifyTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test -import org.rsmod.map.CoordGrid - -class BrineSabreLiquifyTest { - @Test - fun `adds one quarter of final damage up to the live stat cap`() { - assertEquals(12, BrineSabreLiquify.levelsToAdd(base = 99, current = 99, damage = 48)) - assertEquals(6, BrineSabreLiquify.levelsToAdd(base = 99, current = 105, damage = 48)) - assertEquals(0, BrineSabreLiquify.levelsToAdd(base = 99, current = 111, damage = 48)) - } - - @Test - fun `truncates fractional damage boosts and never boosts a zero hit`() { - assertEquals(0, BrineSabreLiquify.levelsToAdd(base = 70, current = 70, damage = 3)) - assertEquals(1, BrineSabreLiquify.levelsToAdd(base = 70, current = 70, damage = 4)) - assertEquals(0, BrineSabreLiquify.levelsToAdd(base = 70, current = 70, damage = 0)) - } - - @Test - fun `accepts only mapped underwater regions`() { - assertTrue(BrineSabreUnderwaterAreas.contains(CoordGrid(1, 46, 148, 20, 20))) - assertTrue(BrineSabreUnderwaterAreas.contains(CoordGrid(1, 59, 144, 20, 20))) - assertTrue(BrineSabreUnderwaterAreas.contains(CoordGrid(0, 58, 160, 20, 20))) - assertFalse(BrineSabreUnderwaterAreas.contains(CoordGrid(0, 46, 148, 20, 20))) - assertFalse(BrineSabreUnderwaterAreas.contains(CoordGrid(0, 50, 50, 20, 20))) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt deleted file mode 100644 index dd3a663ee..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BrutalSwingDamageTest.kt +++ /dev/null @@ -1,30 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class BrutalSwingDamageTest { - @Test - fun `uses the exact success tier ranges`() { - assertEquals(70..109, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 1)) - assertEquals(90..129, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 2)) - assertEquals(110..149, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 3)) - assertEquals(130..169, BrutalSwingDamage.range(normalMax = 100, successfulRolls = 4)) - } - - @Test - fun `floors percentage calculations before applying the exclusive upper bound`() { - assertEquals(51..79, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 1)) - assertEquals(65..93, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 2)) - assertEquals(80..108, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 3)) - assertEquals(94..123, BrutalSwingDamage.range(normalMax = 73, successfulRolls = 4)) - } - - @Test - fun `keeps low maximum ranges valid`() { - assertEquals(0..0, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 1)) - assertEquals(0..0, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 2)) - assertEquals(1..1, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 3)) - assertEquals(1..1, BrutalSwingDamage.range(normalMax = 1, successfulRolls = 4)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt deleted file mode 100644 index 4fdd23671..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/BurningClawDamageTest.kt +++ /dev/null @@ -1,83 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class BurningClawDamageTest { - @Test - fun firstSuccessTierUsesCorrectRangeAndSplit() { - val result = - BurningClawDamage.roll( - maxHit = 39, - rollAccuracy = { true }, - rollInclusive = { range -> - assertEquals(29..68, range) - 29 - }, - rollExclusive = { error("A successful roll must not use the miss fallback.") }, - ) - - assertArrayEquals(intArrayOf(7, 7, 14), result.hits) - assertEquals(15, result.burnChancePercent) - } - - @Test - fun secondSuccessTierUsesCorrectRangeAndSplit() { - val accuracy = listOf(false, true).iterator() - val result = - BurningClawDamage.roll( - maxHit = 39, - rollAccuracy = { accuracy.next() }, - rollInclusive = { range -> - assertEquals(19..58, range) - 16 - }, - rollExclusive = { error("A successful roll must not use the miss fallback.") }, - ) - - assertArrayEquals(intArrayOf(7, 7, 2), result.hits) - assertEquals(30, result.burnChancePercent) - } - - @Test - fun thirdSuccessTierUsesCorrectRangeAndSplit() { - val accuracy = listOf(false, false, true).iterator() - val result = - BurningClawDamage.roll( - maxHit = 39, - rollAccuracy = { accuracy.next() }, - rollInclusive = { range -> - assertEquals(9..48, range) - 9 - }, - rollExclusive = { error("A successful roll must not use the miss fallback.") }, - ) - - assertArrayEquals(intArrayOf(1, 1, 7), result.hits) - assertEquals(45, result.burnChancePercent) - } - - @Test - fun failedAccuracyUsesExactFallbackAndCannotBurn() { - fun misses(roll: Int): BurningClawDamage.Result { - val accuracy = listOf(false, false, false).iterator() - return BurningClawDamage.roll( - maxHit = 39, - rollAccuracy = { accuracy.next() }, - rollInclusive = { error("A failed roll must not use a damage range.") }, - rollExclusive = { - assertEquals(5, it) - roll - }, - ) - } - - assertArrayEquals(intArrayOf(0, 0, 0), misses(0).hits) - assertArrayEquals(intArrayOf(0, 0, 1), misses(1).hits) - assertArrayEquals(intArrayOf(0, 0, 1), misses(2).hits) - assertArrayEquals(intArrayOf(0, 0, 2), misses(3).hits) - assertArrayEquals(intArrayOf(0, 0, 2), misses(4).hits) - assertEquals(0, misses(0).burnChancePercent) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt deleted file mode 100644 index 3b7deea90..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DragonClawsDamageTest.kt +++ /dev/null @@ -1,252 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class DragonClawsDamageTest { - /** Wiki worked example: first hit connects at 35, cascading 35-17-8-9. */ - @Test - fun `first hit connects and cascades down`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(true), - rollRange = { 35 }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertArrayEquals(intArrayOf(35, 17, 8, 9), hits) - } - - /** Wiki worked example: first hit misses, second connects at 30, cascading 0-30-15-16. */ - @Test - fun `second hit connects and cascades down`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, true), - rollRange = { 30 }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertArrayEquals(intArrayOf(0, 30, 15, 16), hits) - } - - /** Wiki worked example: third hit connects at 22, cascading 0-0-22-23. */ - @Test - fun `third hit connects and cascades down`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, true), - rollRange = { 22 }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertArrayEquals(intArrayOf(0, 0, 22, 23), hits) - } - - /** Wiki worked example: fourth hit connects at 46 with no further cascade. */ - @Test - fun `fourth hit connects with no cascade after it`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, false, true), - rollRange = { 46 }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertArrayEquals(intArrayOf(0, 0, 0, 46), hits) - } - - @Test - fun `all four miss with no sympathy damage`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, false, false), - rollRange = { fail() }, - rollSympathyTriggers = { false }, - rollSympathyPattern = { fail() }, - ) - assertArrayEquals(intArrayOf(0, 0, 0, 0), hits) - } - - @Test - fun `all four miss with sympathy damage on hits 1 and 2`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, false, false), - rollRange = { fail() }, - rollSympathyTriggers = { true }, - rollSympathyPattern = { 0 }, - ) - assertArrayEquals(intArrayOf(1, 1, 0, 0), hits) - } - - @Test - fun `all four miss with sympathy damage on hits 3 and 4`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, false, false), - rollRange = { fail() }, - rollSympathyTriggers = { true }, - rollSympathyPattern = { 1 }, - ) - assertArrayEquals(intArrayOf(0, 0, 1, 1), hits) - } - - @Test - fun `all four miss with sympathy damage on hits 1 and 3`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, false, false), - rollRange = { fail() }, - rollSympathyTriggers = { true }, - rollSympathyPattern = { 2 }, - ) - assertArrayEquals(intArrayOf(1, 0, 1, 0), hits) - } - - @Test - fun `all four miss with sympathy damage on hits 2 and 4`() { - val hits = - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, false, false), - rollRange = { fail() }, - rollSympathyTriggers = { true }, - rollSympathyPattern = { 3 }, - ) - assertArrayEquals(intArrayOf(0, 1, 0, 1), hits) - } - - @Test - fun `first hit range is half max to max minus one`() { - var capturedRange: IntRange? = null - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(true), - rollRange = { range -> - capturedRange = range - range.first - }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertEquals(23..45, capturedRange) - } - - @Test - fun `second hit range is three eighths to seven eighths of max`() { - var capturedRange: IntRange? = null - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, true), - rollRange = { range -> - capturedRange = range - range.first - }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertEquals(17..40, capturedRange) - } - - @Test - fun `third hit range is a quarter to three quarters of max`() { - var capturedRange: IntRange? = null - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, true), - rollRange = { range -> - capturedRange = range - range.first - }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertEquals(11..34, capturedRange) - } - - @Test - fun `fourth hit range is a quarter to five quarters of max`() { - var capturedRange: IntRange? = null - DragonClawsDamage.rollHits( - maxHit = 46, - rollAccuracy = accuracySequence(false, false, false, true), - rollRange = { range -> - capturedRange = range - range.first - }, - rollSympathyTriggers = { fail() }, - rollSympathyPattern = { fail() }, - ) - assertEquals(11..57, capturedRange) - } - - @Test - fun `resolveRange forces the top of the range when maxhit is active`() { - val result = - DragonClawsDamage.resolveRange( - min = 5, - max = 20, - isMaxHit = true, - rollInclusive = { _, _ -> fail() }, - rollUpTo = { fail() }, - ) - assertEquals(20, result) - } - - @Test - fun `resolveRange rolls inclusive between min and max when min is positive`() { - val result = - DragonClawsDamage.resolveRange( - min = 5, - max = 20, - isMaxHit = false, - rollInclusive = { lo, hi -> lo + hi }, - rollUpTo = { fail() }, - ) - assertEquals(25, result) - } - - @Test - fun `resolveRange rolls up to max plus one when min is zero or below`() { - val result = - DragonClawsDamage.resolveRange( - min = -3, - max = 20, - isMaxHit = false, - rollInclusive = { _, _ -> fail() }, - rollUpTo = { exclusiveBound -> exclusiveBound }, - ) - assertEquals(21, result) - } - - @Test - fun `resolveRange clamps a negative max down to zero`() { - val result = - DragonClawsDamage.resolveRange( - min = -3, - max = -10, - isMaxHit = true, - rollInclusive = { _, _ -> fail() }, - rollUpTo = { fail() }, - ) - assertEquals(0, result) - } - - /** Returns a lambda that yields the given booleans in order, one per call. */ - private fun accuracySequence(vararg results: Boolean): () -> Boolean { - var index = 0 - return { results[index++] } - } - - private fun fail(): Nothing = throw AssertionError("unexpected call") -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt deleted file mode 100644 index 287432cfb..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/DualMacuahuitlDamageTest.kt +++ /dev/null @@ -1,80 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertArrayEquals -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class DualMacuahuitlDamageTest { - @Test - fun `raises total minimum and maximum then splits the two hits`() { - val hits = - DualMacuahuitlDamage.roll( - normalMax = 20, - rollAccuracy = { true }, - rollDamage = { range -> range.last }, - ) - - assertArrayEquals(intArrayOf(12, 13), hits) - } - - @Test - fun `rolls the two special hits independently`() { - var calls = 0 - val hits = - DualMacuahuitlDamage.roll( - normalMax = 20, - rollAccuracy = { - calls++ - calls == 1 - }, - rollDamage = { range -> range.first }, - ) - - assertArrayEquals(intArrayOf(2, 0), hits) - } - - /** - * Regression test for a real `::maxhit` bug: the special's custom damage roll never checked - * `player.adminMaxHit`, so the cheat silently did nothing for this weapon while working - * correctly on the regular attack (same root cause as the original Dragon claws bug). - */ - @Test - fun `resolveDamage forces the top of the range when maxhit cheat is active`() { - val damage = - DualMacuahuitlDamage.resolveDamage( - range = 4..21, - isMaxHit = true, - rollRandom = { fail() }, - ) - assertEquals(21, damage) - } - - @Test - fun `resolveDamage rolls randomly when maxhit cheat is inactive`() { - var capturedRange: IntRange? = null - val damage = - DualMacuahuitlDamage.resolveDamage( - range = 4..21, - isMaxHit = false, - rollRandom = { range -> - capturedRange = range - range.first - }, - ) - assertEquals(4..21, capturedRange) - assertEquals(4, damage) - } - - @Test - fun `resolveDamage is zero when the range's top is zero or below, regardless of maxhit`() { - val damage = - DualMacuahuitlDamage.resolveDamage( - range = -3..0, - isMaxHit = true, - rollRandom = { fail() }, - ) - assertEquals(0, damage) - } - - private fun fail(): Nothing = throw AssertionError("unexpected call") -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt deleted file mode 100644 index fe58094a5..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/GraniteMaulStyleTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.rsmod.api.combat.commons.CombatStance -import org.rsmod.api.combat.commons.styles.MeleeAttackStyle -import org.junit.jupiter.api.Test - -class GraniteMaulStyleTest { - @Test - fun `stance 1 maps to accurate`() { - assertEquals(MeleeAttackStyle.Accurate, GraniteMaulStyle.resolve(CombatStance.Stance1)) - } - - @Test - fun `stance 2 maps to aggressive`() { - assertEquals(MeleeAttackStyle.Aggressive, GraniteMaulStyle.resolve(CombatStance.Stance2)) - } - - @Test - fun `stances 3 and 4 map to defensive`() { - assertEquals(MeleeAttackStyle.Defensive, GraniteMaulStyle.resolve(CombatStance.Stance3)) - assertEquals(MeleeAttackStyle.Defensive, GraniteMaulStyle.resolve(CombatStance.Stance4)) - } - - @Test - fun `an unrecognized stance falls back to defensive`() { - assertEquals(MeleeAttackStyle.Defensive, GraniteMaulStyle.resolve(null)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt deleted file mode 100644 index 4c958c5ee..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/ImpactMeleeSpecialAttacksTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class ImpactMeleeSpecialAttacksTest { - @Test - fun `bandos drain spends down stats in order, stopping once the amount is spent`() { - // Wiki's own worked example: 20 Defence, 50 Attack, 20 Prayer, an 80 hit drains - // 20 Defence, 20 Prayer (skipping Strength=0), then 40 Attack. - val order = listOf(20, 0, 20, 50, 0, 0) // Defence, Strength, Prayer, Attack, Magic, Ranged - assertEquals(listOf(20, 0, 20, 40, 0, 0), BandosStatDrain.distribute(80, order)) - } - - @Test - fun `bandos drain stops entirely once every stat is exhausted`() { - val order = listOf(5, 5) - assertEquals(listOf(5, 5), BandosStatDrain.distribute(50, order)) - } - - @Test - fun `bandos drain of zero touches nothing`() { - assertEquals(listOf(0, 0), BandosStatDrain.distribute(0, listOf(10, 10))) - } - - @Test - fun `dragon warhammer reduces player defence by 30 percent, rounded down`() { - // Wiki: floor(0.30 * 75) = 22. - assertEquals(22, DragonWarhammerDefenceReduction.playerDrain(75)) - assertEquals(0, DragonWarhammerDefenceReduction.playerDrain(0)) - } - - @Test - fun `dragon warhammer leaves npc defence at 70 percent, rounded down, and stacks multiplicatively`() { - // Wiki: 75 -> 53 -> 38 across two successful hits (not 75 -> 52 -> 34, which would be - // subtracting a fixed 30% of the *original* value each time). - val first = DragonWarhammerDefenceReduction.npcRemaining(75) - assertEquals(53, first) - assertEquals(38, DragonWarhammerDefenceReduction.npcRemaining(first)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt deleted file mode 100644 index 106913272..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/OsmumtenFangDamageTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class OsmumtenFangDamageTest { - /** Wiki's own worked example: a true max hit of 60 rolls between 9 and 60. */ - @Test - fun `minimum hit matches the wiki's worked example`() { - assertEquals(9, OsmumtenFangDamage.minHit(maxHit = 60)) - } - - @Test - fun `minimum hit is 15 percent of the true max hit`() { - assertEquals(15, OsmumtenFangDamage.minHit(maxHit = 100)) - } - - @Test - fun `minimum hit never rounds down to zero`() { - assertEquals(1, OsmumtenFangDamage.minHit(maxHit = 1)) - assertEquals(1, OsmumtenFangDamage.minHit(maxHit = 6)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt deleted file mode 100644 index 54c2616f2..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/SoulreaperAxeDamageTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class SoulreaperAxeDamageTest { - @Test - fun `accuracy multiplier is 12 percent per stack`() { - assertEquals(1.0, SoulreaperAxeDamage.accuracyMultiplier(0)) - assertEquals(1.6, SoulreaperAxeDamage.accuracyMultiplier(5), 1e-9) - } - - @Test - fun `damage multiplier is 6 percent per stack`() { - assertEquals(1.0, SoulreaperAxeDamage.damageMultiplier(0)) - assertEquals(1.3, SoulreaperAxeDamage.damageMultiplier(5), 1e-9) - } - - @Test - fun `minimum damage at 5 stacks is 30 percent of the boosted max hit`() { - // maxHit here is already the boosted value (calculateMeleeMaxHit is called with - // damageMultiplier applied before this), matching the wiki's "30% of that boosted max hit". - assertEquals(30, SoulreaperAxeDamage.minimumDamage(maxHit = 100, stacks = 5)) - } - - @Test - fun `minimum damage is zero with no stacks consumed`() { - assertEquals(0, SoulreaperAxeDamage.minimumDamage(maxHit = 100, stacks = 0)) - } - - @Test - fun `resolveDamage is zero when the accuracy roll fails`() { - val damage = - SoulreaperAxeDamage.resolveDamage( - successful = false, - maxHit = 100, - stacks = 5, - isMaxHit = false, - rollInclusive = { _, _ -> fail() }, - ) - assertEquals(0, damage) - } - - @Test - fun `resolveDamage forces the max hit when maxhit cheat is active`() { - val damage = - SoulreaperAxeDamage.resolveDamage( - successful = true, - maxHit = 100, - stacks = 5, - isMaxHit = true, - rollInclusive = { _, _ -> fail() }, - ) - assertEquals(100, damage) - } - - @Test - fun `resolveDamage rolls between the minimum floor and the max hit`() { - var capturedMin = -1 - var capturedMax = -1 - val damage = - SoulreaperAxeDamage.resolveDamage( - successful = true, - maxHit = 100, - stacks = 5, - isMaxHit = false, - rollInclusive = { lo, hi -> - capturedMin = lo - capturedMax = hi - lo - }, - ) - assertEquals(30, capturedMin) - assertEquals(100, capturedMax) - assertEquals(30, damage) - } - - private fun fail(): Nothing = throw AssertionError("unexpected call") -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt deleted file mode 100644 index 8ab803119..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/UnleashMultipliersTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class UnleashMultipliersTest { - @Test - fun `full 100 percent energy doubles accuracy and boosts damage by 50 percent`() { - val result = UnleashMultipliers.forEnergy(1000) - assertEquals(2.0, result.accuracy, 0.0001) - assertEquals(1.5, result.maxHit, 0.0001) - } - - @Test - fun `half energy gives half the bonus`() { - val result = UnleashMultipliers.forEnergy(500) - assertEquals(1.5, result.accuracy, 0.0001) - assertEquals(1.25, result.maxHit, 0.0001) - } - - @Test - fun `only full five percent chunks of energy count`() { - // 549 is 10 whole 5% (50-energy) chunks plus a partial 11th that shouldn't count. - val result = UnleashMultipliers.forEnergy(549) - assertEquals(UnleashMultipliers.forEnergy(500).accuracy, result.accuracy, 0.0001) - assertEquals(UnleashMultipliers.forEnergy(500).maxHit, result.maxHit, 0.0001) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt deleted file mode 100644 index d4d6cc2e8..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/melee/VestaLongswordFeintDamageTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.rsmod.content.other.special.attacks.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class VestaLongswordFeintDamageTest { - @Test - fun `uses the inclusive 20 to 120 percent normal-max range`() { - assertEquals(20..120, VestaLongswordFeintDamage.range(normalMax = 100)) - } - - @Test - fun `floors both percentage endpoints`() { - assertEquals(14..87, VestaLongswordFeintDamage.range(normalMax = 73)) - } - - @Test - fun `keeps a zero maximum valid`() { - assertEquals(0..0, VestaLongswordFeintDamage.range(normalMax = 0)) - } - - /** - * Regression test for a real `::maxhit` bug: the damage roll never checked - * `player.adminMaxHit`, so the cheat silently did nothing for this weapon's spec. - */ - @Test - fun `resolveDamage forces the top of the range when maxhit cheat is active`() { - val damage = - VestaLongswordFeintDamage.resolveDamage( - range = 20..120, - isMaxHit = true, - rollInclusive = { fail() }, - ) - assertEquals(120, damage) - } - - @Test - fun `resolveDamage rolls randomly when maxhit cheat is inactive`() { - var capturedRange: IntRange? = null - val damage = - VestaLongswordFeintDamage.resolveDamage( - range = 20..120, - isMaxHit = false, - rollInclusive = { range -> - capturedRange = range - range.first - }, - ) - assertEquals(20..120, capturedRange) - assertEquals(20, damage) - } - - @Test - fun `resolveDamage returns the single value directly when the range has no spread`() { - val damage = - VestaLongswordFeintDamage.resolveDamage( - range = 0..0, - isMaxHit = false, - rollInclusive = { fail() }, - ) - assertEquals(0, damage) - } - - private fun fail(): Nothing = throw AssertionError("unexpected call") -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt deleted file mode 100644 index 053a4292f..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ArmadylCrossbowSpecialDamageTest.kt +++ /dev/null @@ -1,37 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class ArmadylCrossbowSpecialDamageTest { - @Test - fun armadylDoublesEachBoltTypesBaseProcChance() { - assertEquals(0.10, ArmadylEnchantedBolt.Opal.armadylActivationChance(true), 0.00001) - assertEquals(0.12, ArmadylEnchantedBolt.Jade.armadylActivationChance(false), 0.00001) - assertEquals(0.50, ArmadylEnchantedBolt.Sapphire.armadylActivationChance(false), 0.00001) - assertEquals(0.20, ArmadylEnchantedBolt.Diamond.armadylActivationChance(false), 0.00001) - assertEquals(0.0, ArmadylEnchantedBolt.Topaz.armadylActivationChance(false), 0.00001) - } - - @Test - fun resolverCoversNormalAndDragonEnchantedBoltNames() { - assertEquals(ArmadylEnchantedBolt.Ruby, ArmadylEnchantedBolt.fromName("Ruby bolts (e)")) - assertEquals( - ArmadylEnchantedBolt.Dragonstone, - ArmadylEnchantedBolt.fromName("Dragonstone dragon bolts (e)"), - ) - assertEquals(null, ArmadylEnchantedBolt.fromName("Diamond bolts")) - } - - @Test - fun fixedBoltDamageHelpersFollowOsrsCapsAndRounding() { - assertEquals(100, ArmadylCrossbowSpecialDamage.rubyDamage(500)) - assertEquals(19, ArmadylCrossbowSpecialDamage.rubyDamage(99)) - assertEquals(9, ArmadylCrossbowSpecialDamage.rubySelfDamage(99)) - assertEquals(11, ArmadylCrossbowSpecialDamage.opalBonus(119)) - assertEquals(7, ArmadylCrossbowSpecialDamage.pearlBonus(119, fieryTarget = true)) - assertEquals(5, ArmadylCrossbowSpecialDamage.sapphirePrayerDrain(119)) - assertEquals(2, ArmadylCrossbowSpecialDamage.sapphirePrayerRestore(5)) - assertEquals(7, ArmadylCrossbowSpecialDamage.onyxHeal(31)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt deleted file mode 100644 index bcb47d5e5..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/BallistaAnimationTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class BallistaAnimationTest { - @Test - fun `light or heavy ballista against a player`() { - assertEquals( - "seq.ballista_special_attack", - BallistaAnimation.resolve(ornamented = false, targetIsNpc = false), - ) - } - - @Test - fun `light or heavy ballista against an npc`() { - assertEquals( - "seq.ballista_special_attack_pvn", - BallistaAnimation.resolve(ornamented = false, targetIsNpc = true), - ) - } - - @Test - fun `ornamented heavy ballista against a player`() { - assertEquals( - "seq.ballista02_special_attack", - BallistaAnimation.resolve(ornamented = true, targetIsNpc = false), - ) - } - - @Test - fun `ornamented heavy ballista against an npc`() { - assertEquals( - "seq.ballista02_special_attack_pvn", - BallistaAnimation.resolve(ornamented = true, targetIsNpc = true), - ) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt deleted file mode 100644 index 051a168c4..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DarkBowDamageTest.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class DarkBowDamageTest { - @Test - fun `descent of darkness range and multiplier match the wiki`() { - assertEquals(5..48, DarkBowDamage.DESCENT_OF_DARKNESS_RANGE) - assertEquals(1.3, DarkBowDamage.DESCENT_OF_DARKNESS_MULTIPLIER) - } - - @Test - fun `descent of dragons range and multiplier match the wiki`() { - assertEquals(8..48, DarkBowDamage.DESCENT_OF_DRAGONS_RANGE) - assertEquals(1.5, DarkBowDamage.DESCENT_OF_DRAGONS_MULTIPLIER) - } - - @Test - fun `a missed hit is zero regardless of the floor`() { - val damage = - DarkBowDamage.resolveHit( - accuracySuccess = false, - damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, - rollRawDamage = { fail() }, - ) - assertEquals(0, damage) - } - - @Test - fun `a landed hit below the floor is raised to the floor`() { - val damage = - DarkBowDamage.resolveHit( - accuracySuccess = true, - damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, - rollRawDamage = { 2 }, - ) - assertEquals(5, damage) - } - - @Test - fun `a landed hit above the cap is lowered to the cap`() { - val damage = - DarkBowDamage.resolveHit( - accuracySuccess = true, - damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, - rollRawDamage = { 90 }, - ) - assertEquals(48, damage) - } - - @Test - fun `a landed hit inside the range passes through unchanged`() { - val damage = - DarkBowDamage.resolveHit( - accuracySuccess = true, - damageRange = DarkBowDamage.DESCENT_OF_DARKNESS_RANGE, - rollRawDamage = { 30 }, - ) - assertEquals(30, damage) - } - - @Test - fun `descent of dragons uses its own higher floor`() { - val damage = - DarkBowDamage.resolveHit( - accuracySuccess = true, - damageRange = DarkBowDamage.DESCENT_OF_DRAGONS_RANGE, - rollRawDamage = { 3 }, - ) - assertEquals(8, damage) - } - - private fun fail(): Nothing = throw AssertionError("unexpected call") -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt deleted file mode 100644 index 7d7a9d740..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonCrossbowSpecialDamageTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class DragonCrossbowSpecialDamageTest { - @Test - fun annihilateScalesOneRawHitForPrimaryAndSecondaries() { - assertEquals(30, DragonCrossbowSpecialDamage.primary(25)) - assertEquals(20, DragonCrossbowSpecialDamage.secondary(25)) - } - - @Test - fun annihilateUsesIntegerFloorRounding() { - assertEquals(1, DragonCrossbowSpecialDamage.primary(1)) - assertEquals(0, DragonCrossbowSpecialDamage.secondary(1)) - assertEquals(0, DragonCrossbowSpecialDamage.primary(0)) - assertEquals(0, DragonCrossbowSpecialDamage.secondary(0)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt deleted file mode 100644 index 7dfa8718e..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/DragonKnifeVariantTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class DragonKnifeVariantTest { - @Test - fun `plain dragon knife is not poisoned`() { - assertFalse(DragonKnifeVariant.isPoisoned("Dragon knife")) - } - - @Test - fun `poisoned variants are detected regardless of dose`() { - assertTrue(DragonKnifeVariant.isPoisoned("Dragon knife(p)")) - assertTrue(DragonKnifeVariant.isPoisoned("Dragon knife(p+)")) - assertTrue(DragonKnifeVariant.isPoisoned("Dragon knife(p++)")) - } - - @Test - fun `detection is case-insensitive`() { - assertTrue(DragonKnifeVariant.isPoisoned("DRAGON KNIFE(P++)")) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt deleted file mode 100644 index 16e1f3c6e..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/EclipseAtlatlDamageTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class EclipseAtlatlDamageTest { - @Test - fun convertsRemainingBurnIntoTheMinimumAndMaximumHit() { - assertEquals(3..26, EclipseAtlatlDamage.hitRange(normalMax = 20, remainingBurn = 6)) - } - - @Test - fun capsBurnConversionAtFiftyDamage() { - assertEquals(25..70, EclipseAtlatlDamage.hitRange(normalMax = 20, remainingBurn = 80)) - } - - @Test - fun keepsTheNormalZeroMinimumWithoutABurn() { - assertEquals(0..20, EclipseAtlatlDamage.hitRange(normalMax = 20, remainingBurn = 0)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt deleted file mode 100644 index 5cd24a679..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MagicBowDamageTest.kt +++ /dev/null @@ -1,49 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class MagicBowDamageTest { - /** - * Wiki: `Maximum Hit = floor(0.5 + (Visible Ranged Level + 10) * (Ammo Ranged Strength + 64) / - * 640)`. Hand-computed: (109 * 124 + 320) / 640 = 13836 / 640 = 21 (floor). - */ - @Test - fun `max hit matches the wiki formula at 99 ranged`() { - assertEquals(21, MagicBowDamage.maxHit(rangedLevel = 99, ammoRangedStrength = 60)) - } - - @Test - fun `max hit matches the wiki formula at low ranged and strength`() { - // (11 * 64 + 320) / 640 = 1024 / 640 = 1 (floor). - assertEquals(1, MagicBowDamage.maxHit(rangedLevel = 1, ammoRangedStrength = 0)) - } - - @Test - fun `max hit ignores nothing but ranged level and ammo strength`() { - // (109 * 79 + 320) / 640 = 8931 / 640 = 13 (floor) - no gear ranged strength, prayer, or - // Slayer helmet bonus enters this formula at all, matching the wiki's stated exclusions. - assertEquals(13, MagicBowDamage.maxHit(rangedLevel = 99, ammoRangedStrength = 15)) - } - - @Test - fun `resolveDamage forces the max hit when maxhit cheat is active`() { - val damage = - MagicBowDamage.resolveDamage(maxHit = 21, isMaxHit = true) { fail() } - assertEquals(21, damage) - } - - @Test - fun `resolveDamage rolls inclusive from zero to the max hit`() { - var capturedRange: IntRange? = null - val damage = - MagicBowDamage.resolveDamage(maxHit = 21, isMaxHit = false) { range -> - capturedRange = range - range.last - } - assertEquals(0..21, capturedRange) - assertEquals(21, damage) - } - - private fun fail(): Nothing = throw AssertionError("unexpected call") -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt deleted file mode 100644 index f89d873e5..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansJavelinBleedDamageTest.kt +++ /dev/null @@ -1,24 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class MorrigansJavelinBleedDamageTest { - @Test - fun `turns forty final initial damage into three ten damage bleed ticks`() { - assertEquals(30, MorrigansJavelinBleedDamage.totalDamage(40)) - assertEquals(listOf(10, 10, 10), MorrigansJavelinBleedDamage.ticks(40)) - } - - @Test - fun `uses the exact final partial tick`() { - assertEquals(11, MorrigansJavelinBleedDamage.totalDamage(15)) - assertEquals(listOf(10, 1), MorrigansJavelinBleedDamage.ticks(15)) - } - - @Test - fun `floors fractional bleed budgets and ignores zero damage`() { - assertEquals(emptyList(), MorrigansJavelinBleedDamage.ticks(1)) - assertEquals(emptyList(), MorrigansJavelinBleedDamage.ticks(0)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt deleted file mode 100644 index 257199207..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/MorrigansThrowingAxeDamageTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class MorrigansThrowingAxeDamageTest { - @Test - fun `uses the Deadman 20 to 120 percent normal-max range`() { - assertEquals( - 20..120, - MorrigansThrowingAxeDamage.range( - normalMax = 100, - minimumPercent = 20, - maximumPercent = 120, - ), - ) - } - - @Test - fun `uses the Bounty Hunter 50 to 150 percent normal-max range`() { - assertEquals( - 50..150, - MorrigansThrowingAxeDamage.range( - normalMax = 100, - minimumPercent = 50, - maximumPercent = 150, - ), - ) - } - - @Test - fun `floors percentage endpoints and retains a valid zero range`() { - assertEquals( - 14..87, - MorrigansThrowingAxeDamage.range( - normalMax = 73, - minimumPercent = 20, - maximumPercent = 120, - ), - ) - assertEquals( - 0..0, - MorrigansThrowingAxeDamage.range( - normalMax = 0, - minimumPercent = 50, - maximumPercent = 150, - ), - ) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt deleted file mode 100644 index 829dd89fc..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/SoulshotDamageTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class SoulshotDamageTest { - @Test - fun `uses only visible ranged level and arrow ranged strength`() { - assertEquals(1, SoulshotDamage.maxHit(1, 7)) - assertEquals(20, SoulshotDamage.maxHit(99, 55)) - assertEquals(21, SoulshotDamage.maxHit(99, 60)) - } - - @Test - fun `rounds with the official half-up term`() { - assertEquals(7, SoulshotDamage.maxHit(50, 7)) - assertEquals(13, SoulshotDamage.maxHit(75, 36)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt deleted file mode 100644 index 6f0781fa0..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/TonalzticsOfRalosSpecialDamageTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class TonalzticsOfRalosSpecialDamageTest { - @Test - fun allTonalzticsHitsUseSeventyFivePercentOfTheNormalMaximum() { - assertEquals(0, TonalzticsOfRalosSpecialDamage.maxHit(0)) - assertEquals(0, TonalzticsOfRalosSpecialDamage.maxHit(1)) - assertEquals(3, TonalzticsOfRalosSpecialDamage.maxHit(4)) - assertEquals(74, TonalzticsOfRalosSpecialDamage.maxHit(99)) - assertEquals(75, TonalzticsOfRalosSpecialDamage.maxHit(100)) - } - - @Test - fun divisionDrainsTenPercentOfCurrentMagicWithIntegerFlooring() { - assertEquals(0, TonalzticsOfRalosSpecialDamage.defenceDrain(0)) - assertEquals(0, TonalzticsOfRalosSpecialDamage.defenceDrain(9)) - assertEquals(1, TonalzticsOfRalosSpecialDamage.defenceDrain(10)) - assertEquals(30, TonalzticsOfRalosSpecialDamage.defenceDrain(300)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt deleted file mode 100644 index 2fcb2a3b1..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ToxicBlowpipeDamageTest.kt +++ /dev/null @@ -1,14 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class ToxicBlowpipeDamageTest { - @Test - fun toxicSiphonHealsHalfOfTheProspectiveRawDamage() { - assertEquals(10, ToxicBlowpipeSpecialDamage.heal(20)) - assertEquals(2, ToxicBlowpipeSpecialDamage.heal(5)) - assertEquals(0, ToxicBlowpipeSpecialDamage.heal(0)) - assertEquals(0, ToxicBlowpipeSpecialDamage.heal(-1)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt deleted file mode 100644 index ca0b1255a..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/WebweaverBowDamageTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class WebweaverBowDamageTest { - @Test - fun swarmCapsEachHitAtCeilingOfFortyPercentOfNormalMaximum() { - assertEquals(0, WebweaverBowSpecialDamage.maxHit(0)) - assertEquals(1, WebweaverBowSpecialDamage.maxHit(1)) - assertEquals(1, WebweaverBowSpecialDamage.maxHit(2)) - assertEquals(2, WebweaverBowSpecialDamage.maxHit(3)) - assertEquals(2, WebweaverBowSpecialDamage.maxHit(5)) - assertEquals(3, WebweaverBowSpecialDamage.maxHit(6)) - assertEquals(4, WebweaverBowSpecialDamage.maxHit(10)) - } -} diff --git a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt b/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt deleted file mode 100644 index 57329f90c..000000000 --- a/content/other/special-attacks/src/test/kotlin/org/rsmod/content/other/special/attacks/ranged/ZaryteCrossbowSpecialDamageTest.kt +++ /dev/null @@ -1,31 +0,0 @@ -package org.rsmod.content.other.special.attacks.ranged - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class ZaryteCrossbowSpecialDamageTest { - @Test - fun rubyBoltUsesTheZarytePassivePercentageAndCap() { - assertEquals(0, ZaryteCrossbowSpecialDamage.rubyDamage(0)) - assertEquals(21, ZaryteCrossbowSpecialDamage.rubyDamage(99)) - assertEquals(110, ZaryteCrossbowSpecialDamage.rubyDamage(1_000)) - assertEquals(9, ZaryteCrossbowSpecialDamage.rubySelfDamage(99)) - } - - @Test - fun boostedBoltValuesUseOfficialIntegerFloors() { - assertEquals(11, ZaryteCrossbowSpecialDamage.opalBonus(99)) - assertEquals(7, ZaryteCrossbowSpecialDamage.pearlBonus(99, fieryTarget = true)) - assertEquals(5, ZaryteCrossbowSpecialDamage.pearlBonus(99, fieryTarget = false)) - assertEquals(21, ZaryteCrossbowSpecialDamage.dragonstoneBonus(99)) - assertEquals(5, ZaryteCrossbowSpecialDamage.sapphirePrayerDrain(99)) - assertEquals(2, ZaryteCrossbowSpecialDamage.sapphirePrayerRestore(5)) - } - - @Test - fun boostedMaximumHitAndPoisonValuesMatchThePassive() { - assertEquals(1.26, ZaryteCrossbowSpecialDamage.DIAMOND_MAX_HIT_MULTIPLIER) - assertEquals(1.32, ZaryteCrossbowSpecialDamage.ONYX_MAX_HIT_MULTIPLIER) - assertEquals(6, ZaryteCrossbowSpecialDamage.EMERALD_POISON_DAMAGE) - } -} diff --git a/content/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt b/content/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt deleted file mode 100644 index 08988948b..000000000 --- a/content/other/special-weapons/src/test/kotlin/org/rsmod/content/other/special/weapons/melee/SoulreaperStackGainTest.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.rsmod.content.other.special.weapons.melee - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Test - -class SoulreaperStackGainTest { - @Test - fun `each attack generates one more stack`() { - assertEquals(1, SoulreaperStackGain.nextStackCount(current = 0, max = 5)) - assertEquals(4, SoulreaperStackGain.nextStackCount(current = 3, max = 5)) - } - - @Test - fun `stacks cap at the maximum and do not overflow`() { - assertEquals(5, SoulreaperStackGain.nextStackCount(current = 4, max = 5)) - assertEquals(5, SoulreaperStackGain.nextStackCount(current = 5, max = 5)) - } -} From b741b00f67d5d5a6e2fee3180deb69f3b35d0db7 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Thu, 3 Sep 2026 23:50:14 -0400 Subject: [PATCH 16/21] chore(commands): remove unused api.specials dependency Left over from the already-removed ::me admin command. Co-Authored-By: Claude Sonnet 5 --- content/other/commands/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/content/other/commands/build.gradle.kts b/content/other/commands/build.gradle.kts index 351dc6359..a2890fd92 100644 --- a/content/other/commands/build.gradle.kts +++ b/content/other/commands/build.gradle.kts @@ -12,7 +12,6 @@ dependencies { implementation(projects.api.dbGateway) implementation(projects.api.mechanics.toxins) implementation(projects.api.pluginCommons) - implementation(projects.api.specials) implementation(projects.api.spellsAutocast) implementation(projects.api.utils.utilsSystem) From 329dedd1c13a14de7ae0e840ce72370a30b49965 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:50:04 -0400 Subject: [PATCH 17/21] fix(special-attacks): Nightmare staff magic-special dispatch, SotD/Nightmare spotanim heights - resolveCombatAttack classifies Nightmare staff's normal attack as melee (weaponCategory=Staff), so activateMeleeSpecial found the weapon's registered magic special and rejected it as the wrong type, silently clearing the special toggle and falling through to a normal hit. activateMeleeSpecial now redirects to activateMagicSpecial when the found special is SpecialAttack.Magic. - attackMagicSpell (the autocast/rune-cast path) never checked the special-attack toggle at all, so activating a special while autocasting a spell with the staff just cast the spell instead. Added the same toggle check/redirect used by the melee/ranged/staff paths, in both PvNCombat and PvPCombat. - Nightmare staff Immolate/Invocate: target hit spotanim height 96 -> 0. - Staff of the Dead family (incl. Toxic): Power of Death spotanim height 100 -> 300. --- .../kotlin/org/rsmod/api/combat/PvNCombat.kt | 17 ++++++++++++++++ .../kotlin/org/rsmod/api/combat/PvPCombat.kt | 20 +++++++++++++++++++ .../rsmod/api/combat/player/PlayerCommons.kt | 11 ++++++++++ .../magic/NightmareStaffSpecialAttacks.kt | 4 ++-- .../magic/StaffOfTheDeadSpecialAttack.kt | 2 +- 5 files changed, 51 insertions(+), 3 deletions(-) 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 1670341be..97460161e 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 @@ -264,6 +264,23 @@ constructor( val attackRate = MAGIC_SPELL_ATTACK_RATE manager.setNextAttackDelay(player, attackRate) + // 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) 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 365d09ae4..24d4dcaf9 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 @@ -305,6 +305,26 @@ constructor( val attackRate = MAGIC_SPELL_ATTACK_RATE manager.setNextAttackDelay(player, attackRate) + // 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) 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 d6f805fb7..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 } 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 index 2a73595c2..3c54d91eb 100644 --- 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 @@ -61,7 +61,7 @@ class NightmareStaffSpecialAttacks : SpecialAttackMap { target.spotanim( spot = VOLATILE_HIT_SPOTANIM, slot = constants.spotanim_slot_combat, - height = SPOTANIM_HEIGHT, + height = 0, ) val successful = @@ -120,7 +120,7 @@ class NightmareStaffSpecialAttacks : SpecialAttackMap { target.spotanim( spot = ELDRITCH_HIT_SPOTANIM, slot = constants.spotanim_slot_combat, - height = SPOTANIM_HEIGHT, + height = 0, ) val successful = 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 index 44903b2a4..2da843ac1 100644 --- 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 @@ -62,7 +62,7 @@ class StaffOfTheDeadSpecialAttack : SpecialAttackMap { 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 = 96 + const val POWER_OF_DEATH_SPOTANIM_HEIGHT = 300 const val POWER_OF_DEATH_MESSAGE = "Spirits of deceased evildoers offer you their protection." } } From 2719a332277afa054d91514156cfcdd25887e70d Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:13:59 -0400 Subject: [PATCH 18/21] fix(special-attacks): Darklight/Arclight/Emberlight Weaken spin glow not rendering The Weaken special's spotanim call omitted slot, defaulting to slot 0 - the same slot resetSpotanim() targets by default elsewhere in the engine, so the spin glow effect was liable to get clobbered before it rendered. Every other special attack in the codebase explicitly passes slot = constants.spotanim_slot_combat; this one didn't. Fixed to match. --- .../other/special/attacks/melee/DemonbaneSpecialAttacks.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index e5a970366..4a48a0353 100644 --- 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 @@ -2,6 +2,7 @@ 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 @@ -59,7 +60,7 @@ class DemonbaneSpecialAttacks : SpecialAttackMap { attack: CombatAttack.Melee, ) { anim("seq.dark_spec_player") - spotanim("spotanim.dark_spec_spot") + spotanim("spotanim.dark_spec_spot", slot = constants.spotanim_slot_combat) sounds.forEachIndexed { index, sound -> soundSynth(sound, delay = index * SOUND_SPACING) } val successful = manager.rollMeleeAccuracy( From 203d0b447e86093141f1e66ae2ca71801bb93792 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:30:46 -0400 Subject: [PATCH 19/21] fix(special-attacks): Crystal halberd sweep colour and ground-tile height Crystal halberd's sweep reused Dragon halberd's red spotanim and the old player-attached spotanim() call, missing the ground-tile spotanimMap() (target's tile, or the midpoint for large NPCs, at height 96) that Dragon halberd was already upgraded to use. HalberdSpecialVisuals now takes a colour parameter (default red) instead of being hardcoded red-only; Crystal halberd passes the real white variant confirmed in the cache (dragon_halberd_special_*_white, ids 1232-1235). --- .../melee/CrystalHalberdSpecialAttack.kt | 26 +++++++++++++++++-- .../attacks/melee/HalberdSpecialVisuals.kt | 25 +++++++++--------- 2 files changed, 37 insertions(+), 14 deletions(-) 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 index f79cff13b..e60bcb64b 100644 --- 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 @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,7 @@ 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 @@ -22,9 +24,10 @@ class CrystalHalberdSpecialAttack 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) + val sweep = Sweep(manager, targets, pvp, worldRepo) registerMelee("obj.crystal_halberd", sweep) registerMelee("obj.crystal_halberd_2500", sweep) } @@ -33,6 +36,7 @@ constructor( private val manager: SpecialAttackManager, private val targets: AreaMeleeTargetSelector, private val pvp: PvPAreaAttackManager, + private val worldRepo: WorldRepository, ) : MeleeSpecialAttack { override suspend fun ProtectedAccess.attack( target: Npc, @@ -49,7 +53,25 @@ constructor( attack: CombatAttack.Melee, ): Boolean { anim("seq.dragon_halberd_special_attack") - spotanim(HalberdSpecialVisuals.forTarget(player.coords, primary.coords)) + // 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) { 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 index 46170d019..b71426aa3 100644 --- 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 @@ -3,20 +3,21 @@ package org.rsmod.content.other.special.attacks.melee import kotlin.math.abs import org.rsmod.map.CoordGrid -/** Selects the red sweep graphic that faces from the wielder toward the primary target. */ +/** + * 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): String { + fun forTarget(source: CoordGrid, target: CoordGrid, color: String = "red"): String { val deltaX = target.x - source.x val deltaZ = target.z - source.z - return if (abs(deltaX) > abs(deltaZ)) { - if (deltaX >= 0) EAST else WEST - } else { - if (deltaZ >= 0) NORTH else SOUTH - } + 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" } - - const val WEST = "spotanim.dragon_halberd_special_west_red" - const val SOUTH = "spotanim.dragon_halberd_special_south_red" - const val NORTH = "spotanim.dragon_halberd_special_north_red" - const val EAST = "spotanim.dragon_halberd_special_east_red" } From 6780393f02012d5c3e0a0d4b0805067cdc3e53c0 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:41:14 -0400 Subject: [PATCH 20/21] style: apply spotless formatting (import order, unused imports, blank lines) --- .../api/combat/manager/PlayerAttackManager.kt | 7 +++---- .../kotlin/org/rsmod/api/combat/PvNCombat.kt | 18 +++++++++--------- .../kotlin/org/rsmod/api/combat/PvPCombat.kt | 14 +++++++------- .../rsmod/api/obj/charges/ObjChargeManager.kt | 2 +- .../rsmod/api/player/ranged/BlowpipeAmmo.kt | 1 + .../api/player/stat/PlayerStatExtensions.kt | 2 +- .../api/player/ui/PlayerInterfaceExtensions.kt | 2 -- .../org/rsmod/api/player/worn/HeldEquipOp.kt | 2 +- .../rsmod/api/specials/SpecialAttackManager.kt | 3 --- .../rsmod/api/weapons/WeaponAttackManager.kt | 3 --- .../interfaces/combat/tab/CombatTabScript.kt | 10 +++++----- .../prayer/tab/scripts/PrayerTabScript.kt | 4 ++-- .../content/other/commands/AdminCommands.kt | 6 +++--- .../special/attacks/SpecialAttackModule.kt | 2 +- .../attacks/melee/VampyreFlailSpecialAttack.kt | 6 +++--- .../ranged/ArmadylCrossbowSpecialAttack.kt | 11 ++++++++++- .../attacks/ranged/DarkBowSpecialAttack.kt | 5 ++--- .../ranged/RosewoodBlowpipeSpecialAttack.kt | 3 +-- .../ranged/ToxicBlowpipeSpecialAttack.kt | 2 +- .../weapons/scripts/TrailblazerToolWieldFix.kt | 2 +- .../org/rsmod/game/entity/PathingEntity.kt | 5 ++--- 21 files changed, 54 insertions(+), 56 deletions(-) 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 a96035b4f..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,10 +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.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.cheat.adminMaxHit import org.rsmod.api.player.interact.NpcInteractions import org.rsmod.api.player.interact.NpcTInteractions import org.rsmod.api.player.interact.PlayerInteractions @@ -203,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) } @@ -220,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 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 97460161e..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,13 +3,9 @@ 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 -import org.rsmod.api.mechanics.toxins.EclipseAtlatlBurnEffect -import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect import org.rsmod.api.combat.player.activateMagicSpecial import org.rsmod.api.combat.player.activateMeleeSpecial import org.rsmod.api.combat.player.activateRangedSpecial @@ -18,14 +14,18 @@ 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.NextCycleRangedSpecialTiming import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.spells.attack.SpellAttackRegistry import org.rsmod.api.spells.attack.attack @@ -207,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. @@ -222,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) @@ -281,7 +281,7 @@ constructor( } } - val spell = spellsReg[RSCM.getReverseMapping(RSCMType.OBJ,attack.spell.obj.id)] + 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 24d4dcaf9..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,21 +12,21 @@ 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.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.combat.weapon.WeaponSpeeds import org.rsmod.api.mechanics.toxins.EclipseAtlatlBurnEffect import org.rsmod.api.mechanics.toxins.WeaponPoisonEffect -import org.rsmod.api.config.constants -import org.rsmod.api.config.refs.params 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.NextCycleRangedSpecialTiming import org.rsmod.api.specials.energy.SpecialAttackEnergy import org.rsmod.api.spells.attack.SpellAttackRegistry import org.rsmod.api.spells.attack.attack @@ -240,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. @@ -257,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 @@ -325,7 +325,7 @@ constructor( } } - val spell = spellsReg[RSCM.getReverseMapping(RSCMType.OBJ,attack.spell.obj.id)] + 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/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 95663f0ae..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 @@ -103,7 +103,7 @@ public class ObjChargeManager { throw IllegalStateException(message) } - val varobj = ServerCacheManager.getVarObj(internal.asRSCM(RSCMType.VAROBJ))?: 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/ranged/BlowpipeAmmo.kt b/api/player/src/main/kotlin/org/rsmod/api/player/ranged/BlowpipeAmmo.kt index 97fe2ce7d..e0cf47ca7 100644 --- 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 @@ -304,6 +304,7 @@ public object BlowpipeAmmo { 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), 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 0a6efe463..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) 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 3db5c7a2f..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 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 587ecf316..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 @@ -124,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/SpecialAttackManager.kt b/api/specials/src/main/kotlin/org/rsmod/api/specials/SpecialAttackManager.kt index 6f3b881a8..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,9 +3,6 @@ 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 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 3ab3df7ca..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,9 +1,6 @@ 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 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 e57170f6d..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,15 +36,15 @@ 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.NextCycleRangedSpecialTiming import org.rsmod.api.specials.SpecialAttackType import org.rsmod.api.specials.combat.NextCycleRangedSpecialAttack import org.rsmod.api.specials.energy.SpecialAttackEnergy @@ -54,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 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 b78503e93..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,8 +6,8 @@ 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.protect.ProtectedAccess 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 import org.rsmod.api.player.ui.ifClose @@ -33,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) } 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 b894ecf65..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 @@ -14,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 @@ -24,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 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 8148182a7..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 @@ -67,8 +67,8 @@ import org.rsmod.content.other.special.attacks.ranged.MorrigansThrowingAxeSpecia 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.ToxicBlowpipeSpecialAttack 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 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 index 75e197fca..e76caf4f5 100644 --- 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 @@ -8,15 +8,15 @@ 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.MeleeSpecialAttack -import org.rsmod.api.player.righthand import org.rsmod.api.specials.combat.MagicSpecialAttack -import org.rsmod.game.inv.InvObj +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 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 index d036d974d..629b2f8c6 100644 --- 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 @@ -5,8 +5,8 @@ import dev.openrune.rscm.RSCMType import dev.openrune.types.ItemServerType import jakarta.inject.Inject import kotlin.math.min -import org.rsmod.api.combat.commons.CombatEffects 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 @@ -178,18 +178,25 @@ internal enum class ArmadylEnchantedBolt( ) { // "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, @@ -198,8 +205,10 @@ internal enum class ArmadylEnchantedBolt( 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, 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 45a4ed1f9..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 @@ -98,7 +97,7 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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" @@ -171,7 +170,7 @@ class DarkBowSpecialAttack @Inject constructor(private val ammunition: RangedAmm 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" 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 index e0273284e..59f86f342 100644 --- 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 @@ -6,11 +6,10 @@ 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.constants import org.rsmod.api.config.refs.params -import org.rsmod.api.player.righthand 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 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 index 5402b6274..c0e0eda01 100644 --- 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 @@ -5,7 +5,6 @@ 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.constants import org.rsmod.api.config.refs.params import org.rsmod.api.mechanics.toxins.NpcPoisonEffectService import org.rsmod.api.mechanics.toxins.impl.PlayerVenom @@ -177,6 +176,7 @@ constructor(private val random: GameRandom, private val poisons: NpcPoisonEffect 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" 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 index ae5035fc0..5ed8273d8 100644 --- 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 @@ -2,8 +2,8 @@ package org.rsmod.content.other.special.weapons.scripts import jakarta.inject.Inject import org.rsmod.api.player.interact.HeldInteractions -import org.rsmod.api.player.worn.HeldEquipResult 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 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 377d69df3..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 From 3801dcd23392ea2743e491caeb7932e415c6abb1 Mon Sep 17 00:00:00 2001 From: jelqscape <321612414+jelqscape@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:16:29 -0400 Subject: [PATCH 21/21] fix soul reapear axe run animation --- .data/raw-cache/server/items.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.data/raw-cache/server/items.toml b/.data/raw-cache/server/items.toml index 3614d0337..aa0232cb4 100644 --- a/.data/raw-cache/server/items.toml +++ b/.data/raw-cache/server/items.toml @@ -23469,6 +23469,7 @@ 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" @@ -23492,6 +23493,7 @@ 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"