feat(throwing): core gargoyle Throwing skill (SA) (#2510)

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.
This commit is contained in:
Kamron Batman 2026-07-02 21:06:11 -07:00 • committed by GitHub
parent d7668df5ee
commit 0bfbdd0764
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 497 additions and 8 deletions

View file

@ -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);

View file

@ -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);
}
/// <summary>
/// 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).
/// </summary>
protected virtual double ModifyHitChance(Mobile attacker, Mobile defender, double chance) => chance;
/// <summary>
/// Allows subclasses to modify the final post-bonus damage (e.g. range-based penalties)
/// before defender mitigation is applied. Mirrors <see cref="ModifyHitChance" />.
/// </summary>
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;

View file

@ -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<BaseShield>(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~
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}