From 0bfbdd0764fa3781531f52a6dd4f2af63f5fd8b6 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:06:11 -0700 Subject: [PATCH] feat(throwing): core gargoyle Throwing skill (SA) (#2510) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes #2376 (@jwvalentine). This is a reviewed, corrected, and scoped-down **Phase 1** of Joe Valentine's throwing implementation — his original commits are cherry-picked here with authorship preserved, plus a fix/scoping pass. Phase 1 lands only the **core gargoyle Throwing skill**; the incomplete content is excised for follow-up PRs (see below). ## What's included (core skill) - `BaseThrown` combat mechanics on top of the existing skeleton: close-quarters penalty, below-min-range penalty, shield penalty, STR-scaled range, overthrow damage penalty. - Base weapons: **Boomerang, Cyclone, SoulGlaive** (gargoyle-only), + blacksmith crafting (SA-gated). - Two symmetric hooks on `BaseWeapon` (`ModifyHitChance`, new `ModifyDamage`) that are inert no-ops for every other weapon. ## Fixes over the original - **Overthrow damage**: was dead code (the swing gate already guarantees you're within `MaxRange`, so the old `ComputeDamage` check never fired). Reimplemented as `finalDamage × 0.53` applied *after* all offensive bonuses via a new `ModifyDamage` hook, firing at the outer range ring. - **`DefMaxRange`**: clamped to `[MinThrowRange, MaxThrowRange]` (uncapped before → e.g. range 13 at 200 Str) and guarded against a latent divide-by-zero. - **Close-quarters mitigation** now uses `RawDex` (matches ServUO/OSI; deterministic under stat mods). - **Return-throw timer** guarded against a deleted/unmapped thrower/target. - Reverted the `MovingShot` change (it rebalanced archery — belongs in a separate PR). - Kept only complex-logic tests (hit-chance/range/damage math); dropped property-value assertions. ## Excised for follow-up PRs (Phase 2/3) 7 named artifacts, the Into-the-Void quest + Agralem, GargishOutcast, the Bladeweaver vendor, and the SA loot tables — these were unwired/non-functional (loot never triggered, quest/creature never spawned) and will return properly wired. `StormCaller` also needs its missing Battle Lust, and the quest its correct void-creature target. ## Verification - Build: 0 warnings / 0 errors. - `UOContent.Tests`: **501/501** passing (the `ModifyDamage` hook causes zero regressions across all weapons). - Full whole-branch review completed: no must-fix defects. --- .../Items/Weapons/Throwing/ThrowingTests.cs | 390 ++++++++++++++++++ .../UOContent/Engines/Craft/DefBlacksmithy.cs | 12 + .../UOContent/Items/Weapons/BaseWeapon.cs | 16 + .../Items/Weapons/Throwing/BaseThrown.cs | 81 +++- .../Items/Weapons/Throwing/Boomerang.cs | 2 + .../Items/Weapons/Throwing/Cyclone.cs | 2 +- .../Items/Weapons/Throwing/SoulGlaive.cs | 2 + 7 files changed, 497 insertions(+), 8 deletions(-) create mode 100644 Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs diff --git a/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs new file mode 100644 index 000000000..da896ba8b --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Items/Weapons/Throwing/ThrowingTests.cs @@ -0,0 +1,390 @@ +using System; +using Server; +using Server.Items; +using Server.Mobiles; +using Server.Tests; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class ThrowingTests +{ + // Exposes protected ModifyHitChance so the math can be verified directly. + private class TestThrown : BaseThrown + { + public TestThrown() : base(0x8FF) { } + + public override int MinThrowRange => 4; + + public double TestModifyHitChance(Mobile attacker, Mobile defender, double chance) => + ModifyHitChance(attacker, defender, chance); + + public int TestModifyDamage(Mobile attacker, Mobile defender, int damage) => + ModifyDamage(attacker, defender, damage); + } + + // Hit chance modifiers + + /// At optimal range with no shield the chance should not change. + [Fact] + public void ModifyHitChance_OptimalRange_NoShield_NoChange() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5000, 500, 0)); + var defender = CreateMobile(map, new Point3D(5005, 500, 0)); // distance 5, within range 4..7 + var weapon = new TestThrown(); + + try + { + attacker.Skills.Throwing.BaseFixedPoint = 0; + attacker.RawDex = 10; + + var result = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.8, result, 10); + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + /// + /// At distance 1 with no throwing skill and minimum dex, close to the full -12% applies. + /// Elf race avoids the Human Jack-of-All-Trades 20.0 skill floor. + /// RawDex clamps to 1 minimum, so mitigation = (0+1)/20 = 0.05 -> penalty = 0.1195. + /// + [Fact] + public void ModifyHitChance_CloseQuarters_MinimumSkill_AppliesNearFullPenalty() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5100, 500, 0)); + var defender = CreateMobile(map, new Point3D(5101, 500, 0)); // distance 1 + var weapon = new TestThrown(); + + try + { + attacker.Race = Race.Elf; // humans have Jack-of-All-Trades (+20.0 floor on all skills) + attacker.Skills.Throwing.BaseFixedPoint = 0; + attacker.RawDex = 1; // minimum + + var result = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.6805, result, 10); + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + /// At distance 0 with 120 skill and 120 dex, (120+120)/20 = 12 caps mitigation, no penalty. + [Fact] + public void ModifyHitChance_CloseQuarters_MaxSkillAndDex_NoChange() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5200, 500, 0)); + var defender = CreateMobile(map, new Point3D(5200, 500, 0)); // same tile, distance 0 + var weapon = new TestThrown(); + + try + { + attacker.Skills.Throwing.BaseFixedPoint = 1200; // 120.0 + attacker.RawDex = 120; + + var result = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.8, result, 10); + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + /// Distance 2 is above melee range but below MinThrowRange of 4, so the flat -12% applies. + [Fact] + public void ModifyHitChance_BelowMinRange_AppliesFlatPenalty() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5300, 500, 0)); + var defender = CreateMobile(map, new Point3D(5302, 500, 0)); // distance 2 + var weapon = new TestThrown(); + + try + { + var result = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.68, result, 10); + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + /// + /// Shield at 100 Parry: 1200/100 = 12% penalty, chance * 0.88. + /// Layer must be set explicitly since tile data is not loaded in tests. + /// + [Fact] + public void ModifyHitChance_Shield_100Parry_AppliesCorrectPenalty() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5400, 500, 0)); + var defender = CreateMobile(map, new Point3D(5405, 500, 0)); // distance 5, optimal + var weapon = new TestThrown(); + var shield = new Buckler { Layer = Layer.TwoHanded }; // tile data not loaded in tests + + try + { + attacker.Skills.Parry.BaseFixedPoint = 1000; // 100.0 + attacker.AddItem(shield); + + var result = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.704, result, 10); + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + /// Shield at 120 Parry: 1200/120 = 10% penalty, chance * 0.90. + [Fact] + public void ModifyHitChance_Shield_120Parry_AppliesCorrectPenalty() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5500, 500, 0)); + var defender = CreateMobile(map, new Point3D(5505, 500, 0)); // distance 5, optimal + var weapon = new TestThrown(); + var shield = new Buckler { Layer = Layer.TwoHanded }; + + try + { + attacker.Skills.Parry.BaseFixedPoint = 1200; // 120.0 + attacker.AddItem(shield); + + var result = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.72, result, 10); + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + /// + /// Shield at 0 Parry hits the 90% cap, chance * 0.10. + /// Elf race prevents JOAT from raising Parry to 20.0. + /// + [Fact] + public void ModifyHitChance_Shield_ZeroParry_CapsAt90Percent() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5600, 500, 0)); + var defender = CreateMobile(map, new Point3D(5605, 500, 0)); // distance 5, optimal + var weapon = new TestThrown(); + var shield = new Buckler { Layer = Layer.TwoHanded }; + + try + { + attacker.Race = Race.Elf; // humans have Jack-of-All-Trades (+20.0 floor on all skills) + attacker.Skills.Parry.BaseFixedPoint = 0; + attacker.AddItem(shield); + + var result = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.08, result, 10); + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + // DefMaxRange STR scaling (uses SoulGlaive: StrReq 60, Min 8, Max 11) + + [Fact] + public void DefMaxRange_AtStrReq_EqualsMinThrowRange() + { + var weapon = new SoulGlaive(); + var attacker = CreateMobile(Map.Felucca, new Point3D(5800, 500, 0)); + try + { + attacker.RawStr = 60; // == AosStrengthReq + attacker.AddItem(weapon); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8 + } + finally + { + weapon.Delete(); + attacker.Delete(); + } + } + + [Fact] + public void DefMaxRange_At140Str_EqualsMaxThrowRange() + { + var weapon = new SoulGlaive(); + var attacker = CreateMobile(Map.Felucca, new Point3D(5810, 500, 0)); + try + { + attacker.RawStr = 140; + attacker.AddItem(weapon); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11 + } + finally + { + weapon.Delete(); + attacker.Delete(); + } + } + + [Fact] + public void DefMaxRange_AboveMaxStr_IsCappedAtMaxThrowRange() + { + var weapon = new SoulGlaive(); + var attacker = CreateMobile(Map.Felucca, new Point3D(5820, 500, 0)); + try + { + attacker.RawStr = 200; // uncapped formula would give 13 + attacker.AddItem(weapon); + Assert.Equal(weapon.MaxThrowRange, weapon.DefMaxRange); // 11, not 13 + } + finally + { + weapon.Delete(); + attacker.Delete(); + } + } + + [Fact] + public void DefMaxRange_BelowStrReq_FlooredAtMinThrowRange() + { + var weapon = new SoulGlaive(); + var attacker = CreateMobile(Map.Felucca, new Point3D(5830, 500, 0)); + try + { + attacker.RawStr = 10; // below StrReq 60 + attacker.AddItem(weapon); + Assert.Equal(weapon.MinThrowRange, weapon.DefMaxRange); // 8, not 6 + } + finally + { + weapon.Delete(); + attacker.Delete(); + } + } + + /// At the outermost admissible tile (dist == MaxRange) a throw loses 47% damage. + [Fact] + public void ModifyDamage_AtMaxRange_Reduces47Percent() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5900, 500, 0)); + var weapon = new TestThrown(); // MinThrowRange 4 -> MaxThrowRange 7 + try + { + attacker.RawStr = 140; // MaxRange == MaxThrowRange == 7 + attacker.AddItem(weapon); + var defender = CreateMobile(map, new Point3D(5907, 500, 0)); // distance 7 == MaxRange + try + { + Assert.Equal(53, weapon.TestModifyDamage(attacker, defender, 100)); + } + finally + { + defender.Delete(); + } + } + finally + { + weapon.Delete(); + attacker.Delete(); + } + } + + /// Inside max range, damage is unchanged. + [Fact] + public void ModifyDamage_WithinRange_NoChange() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5920, 500, 0)); + var weapon = new TestThrown(); + try + { + attacker.RawStr = 140; // MaxRange 7 + attacker.AddItem(weapon); + var defender = CreateMobile(map, new Point3D(5925, 500, 0)); // distance 5 < 7 + try + { + Assert.Equal(100, weapon.TestModifyDamage(attacker, defender, 100)); + } + finally + { + defender.Delete(); + } + } + finally + { + weapon.Delete(); + attacker.Delete(); + } + } + + /// Close-quarters mitigation uses RawDex, so a Dex debuff does not change the penalty. + [Fact] + public void ModifyHitChance_CloseQuarters_UsesRawDex_IgnoresStatMods() + { + var map = Map.Felucca; + var attacker = CreateMobile(map, new Point3D(5940, 500, 0)); + var defender = CreateMobile(map, new Point3D(5941, 500, 0)); // distance 1 + var weapon = new TestThrown(); + try + { + attacker.Race = Race.Elf; // avoid Human JOAT skill floor + attacker.Skills.Throwing.BaseFixedPoint = 0; + attacker.RawDex = 40; + // RawDex-based mitigation = (0 + 40)/20 = 2.0 -> penalty = (12 - 2)/100 = 0.10 + var baseline = weapon.TestModifyHitChance(attacker, defender, 0.8); // 0.70 + + attacker.AddStatMod(new StatMod(StatType.Dex, "curse", -30, TimeSpan.Zero)); // effective Dex 10 + var withMod = weapon.TestModifyHitChance(attacker, defender, 0.8); + + Assert.Equal(0.70, baseline, 10); + Assert.Equal(baseline, withMod, 10); // unchanged because RawDex is used + } + finally + { + attacker.Delete(); + defender.Delete(); + weapon.Delete(); + } + } + + private static PlayerMobile CreateMobile(Map map, Point3D location) + { + var mobile = new PlayerMobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.MoveToWorld(location, map); + return mobile; + } +} diff --git a/Projects/UOContent/Engines/Craft/DefBlacksmithy.cs b/Projects/UOContent/Engines/Craft/DefBlacksmithy.cs index 58001d606..51b26876a 100644 --- a/Projects/UOContent/Engines/Craft/DefBlacksmithy.cs +++ b/Projects/UOContent/Engines/Craft/DefBlacksmithy.cs @@ -667,6 +667,18 @@ public class DefBlacksmithy : CraftSystem index = AddCraft(typeof(DragonChest), 1053114, 1029793, 85.0, 135.0, typeof(RedScales), 1060883, 36, 1060884); SetUseSubRes2(index, true); + if (Core.SA) + { + index = AddCraft(typeof(Boomerang), 1079508, 1095359, 75.0, 125.0, typeof(IronIngot), 1044036, 5, 1044037); + SetNeededExpansion(index, Expansion.SA); + + index = AddCraft(typeof(Cyclone), 1079508, 1095364, 75.0, 125.0, typeof(IronIngot), 1044036, 9, 1044037); + SetNeededExpansion(index, Expansion.SA); + + index = AddCraft(typeof(SoulGlaive), 1079508, 1095363, 75.0, 125.0, typeof(IronIngot), 1044036, 9, 1044037); + SetNeededExpansion(index, Expansion.SA); + } + // Set the overridable material SetSubRes(typeof(IronIngot), 1044022); diff --git a/Projects/UOContent/Items/Weapons/BaseWeapon.cs b/Projects/UOContent/Items/Weapons/BaseWeapon.cs index 4501c0ee3..b28f0d371 100644 --- a/Projects/UOContent/Items/Weapons/BaseWeapon.cs +++ b/Projects/UOContent/Items/Weapons/BaseWeapon.cs @@ -1357,6 +1357,8 @@ public abstract partial class BaseWeapon var chance = ourValue / (theirValue * 2.0) * 1.0 + (double)bonus / 100; + chance = ModifyHitChance(attacker, defender, chance); + if (Core.AOS && chance < 0.02) { chance = 0.02; @@ -1365,6 +1367,19 @@ public abstract partial class BaseWeapon return attacker.CheckSkill(atkSkill.SkillName, chance); } + /// + /// Allows subclasses to modify the final hit chance before the dice roll. + /// Called after all standard AOS bonuses are applied but before the 2% floor. + /// Supports both additive adjustments (chance -= 0.12) and multiplicative ones (chance *= scalar). + /// + protected virtual double ModifyHitChance(Mobile attacker, Mobile defender, double chance) => chance; + + /// + /// Allows subclasses to modify the final post-bonus damage (e.g. range-based penalties) + /// before defender mitigation is applied. Mirrors . + /// + protected virtual int ModifyDamage(Mobile attacker, Mobile defender, int damage) => damage; + public virtual TimeSpan GetDelay(Mobile m) { double speed = Speed; @@ -1874,6 +1889,7 @@ public abstract partial class BaseWeapon percentageBonus = Math.Min(percentageBonus, 300); damage = AOS.Scale(damage, 100 + percentageBonus); + damage = ModifyDamage(attacker, defender, damage); var defLoc = new WorldLocation(defender); var bcAtt = attacker as BaseCreature; diff --git a/Projects/UOContent/Items/Weapons/Throwing/BaseThrown.cs b/Projects/UOContent/Items/Weapons/Throwing/BaseThrown.cs index 66821f385..3905aea14 100644 --- a/Projects/UOContent/Items/Weapons/Throwing/BaseThrown.cs +++ b/Projects/UOContent/Items/Weapons/Throwing/BaseThrown.cs @@ -14,31 +14,92 @@ public abstract partial class BaseThrown : BaseRanged public virtual int MaxThrowRange => MinThrowRange + 3; + // Dynamic max range scaled by attacker Strength, clamped to the weapon's throw band. + // At StrReq the effective range equals MinThrowRange; at 140 Str it reaches MaxThrowRange. public override int DefMaxRange { get { - var baseRange = MaxThrowRange; + if (Parent is not Mobile attacker) + { + return MaxThrowRange; + } - return Parent is Mobile attacker - ? baseRange - 3 + (attacker.Str - AosStrengthReq) / ((140 - AosStrengthReq) / 3) - : baseRange; + var divisor = (140 - AosStrengthReq) / 3; + if (divisor <= 0) + { + return MaxThrowRange; + } + + // Scale up from MinThrowRange so the base matches the clamp floor even if a + // subclass overrides MaxThrowRange to something other than MinThrowRange + 3. + var scaled = MinThrowRange + (attacker.Str - AosStrengthReq) / divisor; + return Math.Clamp(scaled, MinThrowRange, MaxThrowRange); } } public override int EffectID => ItemID; + // Throwing weapons require no ammo — the weapon itself is the projectile. public override Type AmmoType => null; - public override Item Ammo => null; public override int DefHitSound => 0x5D3; public override int DefMissSound => 0x5D4; public override SkillName DefSkill => SkillName.Throwing; + public override SkillName AccuracySkill => SkillName.Throwing; public override WeaponAnimation DefAnimation => WeaponAnimation.Throwing; + // Throwing-specific hit chance modifiers (applied via BaseWeapon.ModifyHitChance hook). + protected override double ModifyHitChance(Mobile attacker, Mobile defender, double chance) + { + // Use Chebyshev distance — consistent with all UO range mechanics. + var distance = Math.Max( + Math.Abs(attacker.X - defender.X), + Math.Abs(attacker.Y - defender.Y) + ); + + if (distance <= 1) + { + // Close-quarters penalty: up to -12%, mitigated by (Throwing + RawDex) / 20. + // At 240 combined (120 skill + 120 RawDex) the penalty is fully mitigated. + var throwSkill = attacker.Skills[SkillName.Throwing].Value; + var mitigation = Math.Min(12.0, (throwSkill + attacker.RawDex) / 20.0); + chance -= (12.0 - mitigation) / 100.0; + } + else if (distance < MinThrowRange) + { + // Below minimum throw range (but not melee): flat -12% hit chance. + chance -= 0.12; + } + + // Shield penalty: equipping a shield while throwing reduces hit chance. + // Penalty = 1200 / Parry (capped at 90%), applied multiplicatively. + // High Parrying skill reduces the penalty significantly. + if (attacker.FindItemOnLayer(Layer.TwoHanded) != null) + { + var parry = attacker.Skills[SkillName.Parry].Value; + var penalty = parry > 0.0 ? 1200.0 / parry : 90.0; + chance *= 1.0 - Math.Min(90.0, penalty) / 100.0; + } + + return chance; + } + + // Overthrow: a throw that reaches the edge of its (STR-scaled) range lands with 47% less + // damage, applied on top of all offensive bonuses. MaxRange is the dynamic DefMaxRange. + protected override int ModifyDamage(Mobile attacker, Mobile defender, int damage) + { + if (!attacker.InRange(defender.Location, MaxRange - 1)) + { + damage = damage * 53 / 100; + } + + return damage; + } + public override bool OnFired(Mobile attacker, Mobile defender) { if (!attacker.InRange(defender, 1)) @@ -73,12 +134,12 @@ public abstract partial class BaseThrown : BaseRanged public virtual void Return(Mobile thrower, Mobile target, WorldLocation worldLocation) { - if (thrower == null) + if (thrower?.Deleted != false || thrower.Map == null || thrower.Map == Map.Internal) { return; } - if (target != null) + if (target?.Deleted == false) { target.MovingEffect(thrower, EffectID, 18, 1, false, false, Hue, 0); } @@ -102,4 +163,10 @@ public abstract partial class BaseThrown : BaseRanged ); } } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + list.Add(1149791, MinThrowRange); // Min Throw Range: ~1_val~ + } } diff --git a/Projects/UOContent/Items/Weapons/Throwing/Boomerang.cs b/Projects/UOContent/Items/Weapons/Throwing/Boomerang.cs index bae88c893..34d802cf7 100644 --- a/Projects/UOContent/Items/Weapons/Throwing/Boomerang.cs +++ b/Projects/UOContent/Items/Weapons/Throwing/Boomerang.cs @@ -19,4 +19,6 @@ public partial class Boomerang : BaseThrown public override float MlSpeed => 2.75f; public override int InitMinHits => 31; public override int InitMaxHits => 60; + + public override int RequiredRaces => Race.AllowGargoylesOnly; } diff --git a/Projects/UOContent/Items/Weapons/Throwing/Cyclone.cs b/Projects/UOContent/Items/Weapons/Throwing/Cyclone.cs index f2b6812c1..db9a14ec1 100644 --- a/Projects/UOContent/Items/Weapons/Throwing/Cyclone.cs +++ b/Projects/UOContent/Items/Weapons/Throwing/Cyclone.cs @@ -20,5 +20,5 @@ public partial class Cyclone : BaseThrown public override int InitMinHits => 31; public override int InitMaxHits => 60; - + public override int RequiredRaces => Race.AllowGargoylesOnly; } diff --git a/Projects/UOContent/Items/Weapons/Throwing/SoulGlaive.cs b/Projects/UOContent/Items/Weapons/Throwing/SoulGlaive.cs index 434d0c166..6a14de3c8 100644 --- a/Projects/UOContent/Items/Weapons/Throwing/SoulGlaive.cs +++ b/Projects/UOContent/Items/Weapons/Throwing/SoulGlaive.cs @@ -20,4 +20,6 @@ public partial class SoulGlaive : BaseThrown public override int InitMinHits => 31; public override int InitMaxHits => 65; + + public override int RequiredRaces => Race.AllowGargoylesOnly; }