feat: Implements Throwing skill, weapons, artifacts, and crafting (SA)

Interim Commit.  TODO: NPC/creature work.

- Adds BaseThrown with close-quarters/range/shield hit chance modifiers,
  returning weapon effect, STR-scaled max range, and overthrow penalty
- Adds Boomerang, Cyclone, and SoulGlaive (Gargoyle-only throwing weapons)
- Adds Bladeweaver NPC vendor selling throwing weapons and ammo
- Adds DefBlacksmithy recipes for all three throwing weapons (Core.SA)
- Adds SA weapon type arrays to Loot (SAWeaponTypes, SARangedWeaponTypes)
  with isStygian parameter on random weapon/ranged/jewelry helpers
- Adds AbyssReaver, BansheesCall, RaptorClaw, StoneSlithClaw, StormCaller,
  ValkyriesGlaive, WindOfCorruption throwing weapon artifacts
- Fixes MovingShot BaseMana (15 -> 20) and TOL-conditional accuracy penalty
- Adds 58 unit tests covering weapon properties, race restrictions,
  hit chance math, artifact properties, and loot table contents
This commit is contained in:
Joe Valentine 2026-03-15 20:04:24 -04:00 committed by Kamron Batman
parent a28a32f46d
commit cf3efaa2b7
26 changed files with 1175 additions and 9 deletions

View file

@ -0,0 +1,767 @@
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);
}
// Weapon properties
[Theory]
[InlineData(typeof(Boomerang))]
[InlineData(typeof(Cyclone))]
[InlineData(typeof(SoulGlaive))]
public void ThrowingWeapon_AccuracySkill_IsThrowingSkill(Type weaponType)
{
var weapon = (BaseThrown)Activator.CreateInstance(weaponType);
try
{
Assert.Equal(SkillName.Throwing, weapon.AccuracySkill);
}
finally
{
weapon.Delete();
}
}
[Theory]
[InlineData(typeof(Boomerang))]
[InlineData(typeof(Cyclone))]
[InlineData(typeof(SoulGlaive))]
public void ThrowingWeapon_DefSkill_IsThrowingSkill(Type weaponType)
{
var weapon = (BaseThrown)Activator.CreateInstance(weaponType);
try
{
Assert.Equal(SkillName.Throwing, weapon.DefSkill);
}
finally
{
weapon.Delete();
}
}
[Theory]
[InlineData(typeof(Boomerang), 4, 7)]
[InlineData(typeof(Cyclone), 6, 9)]
[InlineData(typeof(SoulGlaive), 8, 11)]
public void ThrowingWeapon_ThrowRange_IsCorrect(Type weaponType, int expectedMin, int expectedMax)
{
var weapon = (BaseThrown)Activator.CreateInstance(weaponType);
try
{
Assert.Equal(expectedMin, weapon.MinThrowRange);
Assert.Equal(expectedMax, weapon.MaxThrowRange);
}
finally
{
weapon.Delete();
}
}
// Race restriction
[Theory]
[InlineData(typeof(Boomerang))]
[InlineData(typeof(Cyclone))]
[InlineData(typeof(SoulGlaive))]
public void ThrowingWeapon_RequiredRaces_IsGargoylesOnly(Type weaponType)
{
var weapon = (BaseThrown)Activator.CreateInstance(weaponType);
try
{
Assert.Equal(Race.AllowGargoylesOnly, weapon.RequiredRaces);
}
finally
{
weapon.Delete();
}
}
[Theory]
[InlineData(typeof(Boomerang))]
[InlineData(typeof(Cyclone))]
[InlineData(typeof(SoulGlaive))]
public void ThrowingWeapon_CheckRace_AllowsGargoyle(Type weaponType)
{
var weapon = (BaseThrown)Activator.CreateInstance(weaponType);
var mobile = CreateMobile(Map.Felucca, new Point3D(5700, 500, 0));
try
{
mobile.Race = Race.Gargoyle;
Assert.True(weapon.CheckRace(mobile, message: false));
}
finally
{
weapon.Delete();
mobile.Delete();
}
}
[Theory]
[InlineData(typeof(Boomerang))]
[InlineData(typeof(Cyclone))]
[InlineData(typeof(SoulGlaive))]
public void ThrowingWeapon_CheckRace_BlocksHuman(Type weaponType)
{
var weapon = (BaseThrown)Activator.CreateInstance(weaponType);
var mobile = CreateMobile(Map.Felucca, new Point3D(5720, 500, 0));
try
{
mobile.Race = Race.Human;
Assert.False(weapon.CheckRace(mobile, message: false));
}
finally
{
weapon.Delete();
mobile.Delete();
}
}
[Theory]
[InlineData(typeof(Boomerang))]
[InlineData(typeof(Cyclone))]
[InlineData(typeof(SoulGlaive))]
public void ThrowingWeapon_CheckRace_BlocksElf(Type weaponType)
{
var weapon = (BaseThrown)Activator.CreateInstance(weaponType);
var mobile = CreateMobile(Map.Felucca, new Point3D(5740, 500, 0));
try
{
mobile.Race = Race.Elf;
Assert.False(weapon.CheckRace(mobile, message: false));
}
finally
{
weapon.Delete();
mobile.Delete();
}
}
// AbyssReaver
[Fact]
public void AbyssReaver_IsInstanceOfCyclone()
{
var weapon = new AbyssReaver();
try
{
Assert.IsType<Cyclone>(weapon, exactMatch: false);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void AbyssReaver_LabelNumber_IsAbyssReaver()
{
var weapon = new AbyssReaver();
try
{
Assert.Equal(1112694, weapon.LabelNumber);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void AbyssReaver_Slayer_IsExorcism()
{
var weapon = new AbyssReaver();
try
{
Assert.Equal(SlayerName.Exorcism, weapon.Slayer);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void AbyssReaver_WeaponDamage_IsInRange()
{
var weapon = new AbyssReaver();
try
{
Assert.InRange(weapon.Attributes.WeaponDamage, 25, 35);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void AbyssReaver_ThrowingSkillBonus_IsInRange()
{
var weapon = new AbyssReaver();
try
{
weapon.SkillBonuses.GetValues(0, out var skill, out var bonus);
Assert.Equal(SkillName.Throwing, skill);
Assert.InRange(bonus, 5.0, 10.0);
}
finally
{
weapon.Delete();
}
}
// Hit chance modifiers
/// <summary>At optimal range with no shield the chance should not change.</summary>
[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();
}
}
/// <summary>
/// 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.
/// </summary>
[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();
}
}
/// <summary>At distance 0 with 120 skill and 120 dex, (120+120)/20 = 12 caps mitigation, no penalty.</summary>
[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();
}
}
/// <summary>Distance 2 is above melee range but below MinThrowRange of 4, so the flat -12% applies.</summary>
[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();
}
}
/// <summary>
/// Shield at 100 Parry: 1200/100 = 12% penalty, chance * 0.88.
/// Layer must be set explicitly since tile data is not loaded in tests.
/// </summary>
[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();
}
}
/// <summary>Shield at 120 Parry: 1200/120 = 10% penalty, chance * 0.90.</summary>
[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();
}
}
/// <summary>
/// Shield at 0 Parry hits the 90% cap, chance * 0.10.
/// Elf race prevents JOAT from raising Parry to 20.0.
/// </summary>
[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();
}
}
// Throwing artifacts
[Fact]
public void ValkyriesGlaive_IsInstanceOfSoulGlaive()
{
var weapon = new ValkyriesGlaive();
try
{
Assert.IsType<SoulGlaive>(weapon, exactMatch: false);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void ValkyriesGlaive_LabelNumber_IsCorrect()
{
var weapon = new ValkyriesGlaive();
try
{
Assert.Equal(1113531, weapon.LabelNumber);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void ValkyriesGlaive_Slayer_IsSilver()
{
var weapon = new ValkyriesGlaive();
try
{
Assert.Equal(SlayerName.Silver, weapon.Slayer);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void ValkyriesGlaive_InitHits_Is255()
{
var weapon = new ValkyriesGlaive();
try
{
Assert.Equal(255, weapon.InitMinHits);
Assert.Equal(255, weapon.InitMaxHits);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void BansheesCall_IsInstanceOfCyclone()
{
var weapon = new BansheesCall();
try
{
Assert.IsType<Cyclone>(weapon, exactMatch: false);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void BansheesCall_LabelNumber_IsCorrect()
{
var weapon = new BansheesCall();
try
{
Assert.Equal(1113529, weapon.LabelNumber);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void BansheesCall_ElementDamage_IsColdOnly()
{
var weapon = new BansheesCall();
try
{
Assert.Equal(100, weapon.AosElementDamages.Cold);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void StormCaller_IsInstanceOfBoomerang()
{
var weapon = new StormCaller();
try
{
Assert.IsType<Boomerang>(weapon, exactMatch: false);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void StormCaller_LabelNumber_IsCorrect()
{
var weapon = new StormCaller();
try
{
Assert.Equal(1113530, weapon.LabelNumber);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void StormCaller_ElementDamages_AreEqualFiveSplit()
{
var weapon = new StormCaller();
try
{
Assert.Equal(20, weapon.AosElementDamages.Physical);
Assert.Equal(20, weapon.AosElementDamages.Fire);
Assert.Equal(20, weapon.AosElementDamages.Cold);
Assert.Equal(20, weapon.AosElementDamages.Poison);
Assert.Equal(20, weapon.AosElementDamages.Energy);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void RaptorClaw_IsInstanceOfBoomerang()
{
var weapon = new RaptorClaw();
try
{
Assert.IsType<Boomerang>(weapon, exactMatch: false);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void RaptorClaw_LabelNumber_IsCorrect()
{
var weapon = new RaptorClaw();
try
{
Assert.Equal(1112394, weapon.LabelNumber);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void RaptorClaw_Slayer_IsSilver()
{
var weapon = new RaptorClaw();
try
{
Assert.Equal(SlayerName.Silver, weapon.Slayer);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void StoneSlithClaw_IsInstanceOfCyclone()
{
var weapon = new StoneSlithClaw();
try
{
Assert.IsType<Cyclone>(weapon, exactMatch: false);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void StoneSlithClaw_LabelNumber_IsCorrect()
{
var weapon = new StoneSlithClaw();
try
{
Assert.Equal(1112393, weapon.LabelNumber);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void StoneSlithClaw_Slayer_IsDaemonDismissal()
{
var weapon = new StoneSlithClaw();
try
{
Assert.Equal(SlayerName.DaemonDismissal, weapon.Slayer);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void WindOfCorruption_IsInstanceOfCyclone()
{
var weapon = new WindOfCorruption();
try
{
Assert.IsType<Cyclone>(weapon, exactMatch: false);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void WindOfCorruption_LabelNumber_IsCorrect()
{
var weapon = new WindOfCorruption();
try
{
Assert.Equal(1150358, weapon.LabelNumber);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void WindOfCorruption_Slayer_IsFey()
{
var weapon = new WindOfCorruption();
try
{
Assert.Equal(SlayerName.Fey, weapon.Slayer);
}
finally
{
weapon.Delete();
}
}
[Fact]
public void WindOfCorruption_ElementDamage_IsChaosOnly()
{
var weapon = new WindOfCorruption();
try
{
Assert.Equal(100, weapon.AosElementDamages.Chaos);
}
finally
{
weapon.Delete();
}
}
// MovingShot
/// <summary>BaseMana was raised from 15 to 20 to match the OSI reference.</summary>
[Fact]
public void MovingShot_BaseMana_Is20()
{
Assert.Equal(20, WeaponAbility.MovingShot.BaseMana);
}
/// <summary>Pre-TOL accuracy penalty is -25; -35 applies only on TOL+ servers.</summary>
[Fact]
public void MovingShot_AccuracyBonus_IsNegative25_PreTOL()
{
if (!Core.TOL)
{
Assert.Equal(-25, WeaponAbility.MovingShot.AccuracyBonus);
}
}
// Loot tables
[Fact]
public void SAWeaponTypes_ContainsAllExpectedTypes()
{
Assert.Contains(typeof(DiscMace), Loot.SAWeaponTypes);
Assert.Contains(typeof(GargishTalwar), Loot.SAWeaponTypes);
Assert.Contains(typeof(DualPointedSpear), Loot.SAWeaponTypes);
Assert.Contains(typeof(GlassStaff), Loot.SAWeaponTypes);
Assert.Contains(typeof(DualShortAxes), Loot.SAWeaponTypes);
Assert.Contains(typeof(GlassSword), Loot.SAWeaponTypes);
}
[Fact]
public void SARangedWeaponTypes_ContainsAllThrowingWeapons()
{
Assert.Contains(typeof(Boomerang), Loot.SARangedWeaponTypes);
Assert.Contains(typeof(Cyclone), Loot.SARangedWeaponTypes);
Assert.Contains(typeof(SoulGlaive), Loot.SARangedWeaponTypes);
}
[Fact]
public void SARangedWeaponTypes_ContainsExactlyThreeEntries()
{
Assert.Equal(3, Loot.SARangedWeaponTypes.Length);
}
private static PlayerMobile CreateMobile(Map map, Point3D location)
{
var mobile = new PlayerMobile(World.NewMobile);
mobile.DefaultMobileInit();
mobile.MoveToWorld(location, map);
return mobile;
}
}

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

@ -6,8 +6,8 @@ namespace Server.Items
/// </summary>
public class MovingShot : WeaponAbility
{
public override int BaseMana => 15;
public override int AccuracyBonus => -25;
public override int BaseMana => 20;
public override int AccuracyBonus => Core.TOL ? -35 : -25;
public override bool ValidatesDuringHit => false;

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,13 @@ 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;
public virtual TimeSpan GetDelay(Mobile m)
{
double speed = Speed;

View file

@ -0,0 +1,17 @@
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class AbyssReaver : Cyclone
{
[Constructible]
public AbyssReaver()
{
SkillBonuses.SetValues(0, SkillName.Throwing, Utility.RandomMinMax(5, 10));
Attributes.WeaponDamage = Utility.RandomMinMax(25, 35);
Slayer = SlayerName.Exorcism;
}
public override int LabelNumber => 1112694; // Abyss Reaver
}

View file

@ -0,0 +1,25 @@
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class BansheesCall : Cyclone
{
[Constructible]
public BansheesCall()
{
Hue = 1266;
Attributes.BonusStr = 5;
Attributes.WeaponSpeed = 30;
Attributes.WeaponDamage = 50;
WeaponAttributes.HitHarm = 40;
WeaponAttributes.HitLeechHits = 45;
Velocity = 35;
AosElementDamages.Cold = 100;
}
public override int LabelNumber => 1113529; // Banshee's Call
public override int InitMinHits => 255;
public override int InitMaxHits => 255;
}

View file

@ -14,6 +14,8 @@ public abstract partial class BaseThrown : BaseRanged
public virtual int MaxThrowRange => MinThrowRange + 3;
// Dynamic max range scaled by attacker Strength.
// At StrReq the effective range equals MinThrowRange; at 140 Str it reaches MaxThrowRange.
public override int DefMaxRange
{
get
@ -28,17 +30,68 @@ public abstract partial class BaseThrown : BaseRanged
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 + Dex) / 20.
// At 240 combined (120 skill + 120 dex) the penalty is fully mitigated.
var throwSkill = attacker.Skills[SkillName.Throwing].Value;
var mitigation = Math.Min(12.0, (throwSkill + attacker.Dex) / 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 penalty: -47% damage when target is beyond the attacker's current max range.
// MaxRange returns DefMaxRange (STR-scaled), so this reflects the dynamic per-attack value.
public override int ComputeDamage(Mobile attacker, Mobile defender)
{
var damage = base.ComputeDamage(attacker, defender);
if (!attacker.InRange(defender.Location, MaxRange))
{
damage = (int)(damage * 0.53);
}
return damage;
}
public override bool OnFired(Mobile attacker, Mobile defender)
{
if (!attacker.InRange(defender, 1))
@ -102,4 +155,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

@ -0,0 +1,23 @@
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class RaptorClaw : Boomerang
{
[Constructible]
public RaptorClaw()
{
Hue = 53;
Attributes.AttackChance = 12;
Attributes.WeaponSpeed = 30;
Attributes.WeaponDamage = 35;
Slayer = SlayerName.Silver;
WeaponAttributes.HitLeechStam = 40;
}
public override int LabelNumber => 1112394; // Raptor Claw
public override int InitMinHits => 255;
public override int InitMaxHits => 255;
}

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

View file

@ -0,0 +1,23 @@
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class StoneSlithClaw : Cyclone
{
[Constructible]
public StoneSlithClaw()
{
Hue = 1150;
Attributes.WeaponSpeed = 25;
Attributes.WeaponDamage = 45;
Slayer = SlayerName.DaemonDismissal;
WeaponAttributes.HitHarm = 40;
WeaponAttributes.HitLowerDefend = 40;
}
public override int LabelNumber => 1112393; // Stone Slith Claw
public override int InitMinHits => 255;
public override int InitMaxHits => 255;
}

View file

@ -0,0 +1,28 @@
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class StormCaller : Boomerang
{
[Constructible]
public StormCaller()
{
Hue = 456;
Attributes.BonusStr = 5;
Attributes.WeaponSpeed = 30;
Attributes.WeaponDamage = 40;
WeaponAttributes.HitLightning = 40;
WeaponAttributes.HitLowerDefend = 30;
AosElementDamages.Physical = 20;
AosElementDamages.Fire = 20;
AosElementDamages.Cold = 20;
AosElementDamages.Poison = 20;
AosElementDamages.Energy = 20;
}
public override int LabelNumber => 1113530; // Storm Caller
public override int InitMinHits => 255;
public override int InitMaxHits => 255;
}

View file

@ -0,0 +1,24 @@
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class ValkyriesGlaive : SoulGlaive
{
[Constructible]
public ValkyriesGlaive()
{
Hue = 1651;
Attributes.SpellChanneling = 1;
Attributes.BonusStr = 5;
Attributes.WeaponSpeed = 20;
Attributes.WeaponDamage = 20;
Slayer = SlayerName.Silver;
WeaponAttributes.HitFireball = 40;
}
public override int LabelNumber => 1113531; // Valkyrie's Glaive
public override int InitMinHits => 255;
public override int InitMaxHits => 255;
}

View file

@ -0,0 +1,24 @@
using ModernUO.Serialization;
namespace Server.Items;
[SerializationGenerator(0)]
public partial class WindOfCorruption : Cyclone
{
[Constructible]
public WindOfCorruption()
{
Hue = 1171;
Attributes.WeaponSpeed = 30;
Attributes.WeaponDamage = 50;
Slayer = SlayerName.Fey;
WeaponAttributes.HitLeechStam = 40;
WeaponAttributes.HitLowerDefend = 40;
AosElementDamages.Chaos = 100;
}
public override int LabelNumber => 1150358; // Wind of Corruption
public override int InitMinHits => 255;
public override int InitMaxHits => 255;
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.AbyssReaver"
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.BansheesCall"
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.RaptorClaw"
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.StoneSlithClaw"
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.StormCaller"
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.ValkyriesGlaive"
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Items.WindOfCorruption"
}

View file

@ -0,0 +1,4 @@
{
"version": 0,
"type": "Server.Mobiles.Bladeweaver"
}

View file

@ -6,6 +6,17 @@ namespace Server
{
public static class Loot
{
public static Type[] SAWeaponTypes { get; } =
{
typeof(DiscMace), typeof(GargishTalwar), typeof(DualPointedSpear),
typeof(GlassStaff), typeof(DualShortAxes), typeof(GlassSword)
};
public static Type[] SARangedWeaponTypes { get; } =
{
typeof(Boomerang), typeof(Cyclone), typeof(SoulGlaive)
};
public static Type[] MLWeaponTypes { get; } =
{
typeof(AssassinSpike), typeof(DiamondMace), typeof(ElvenMachete),
@ -374,12 +385,18 @@ namespace Server
return Construct<BaseClothing>(ClothingTypes);
}
private static readonly Type[][] _saRangedWeaponTypes = [SARangedWeaponTypes, AosRangedWeaponTypes, RangedWeaponTypes];
private static readonly Type[][] _mlRangedWeaponTypes = [MLRangedWeaponTypes, AosRangedWeaponTypes, RangedWeaponTypes];
private static readonly Type[][] _seRangedWeaponTypes = [SERangedWeaponTypes, AosRangedWeaponTypes, RangedWeaponTypes];
private static readonly Type[][] _aosRangedWeaponTypes = [AosRangedWeaponTypes, RangedWeaponTypes];
public static BaseWeapon RandomRangedWeapon(bool inTokuno = false, bool isMondain = false)
public static BaseWeapon RandomRangedWeapon(bool inTokuno = false, bool isMondain = false, bool isStygian = false)
{
if (Core.SA && isStygian)
{
return Construct<BaseWeapon>(_saRangedWeaponTypes);
}
if (Core.ML && isMondain)
{
return Construct<BaseWeapon>(_mlRangedWeaponTypes);
@ -398,12 +415,18 @@ namespace Server
return Construct<BaseWeapon>(RangedWeaponTypes);
}
private static readonly Type[][] _saWeaponTypes = [SAWeaponTypes, AosWeaponTypes, WeaponTypes];
private static readonly Type[][] _mlWeaponTypes = [MLWeaponTypes, AosWeaponTypes, WeaponTypes];
private static readonly Type[][] _seWeaponTypes = [SEWeaponTypes, AosWeaponTypes, WeaponTypes];
private static readonly Type[][] _aosWeaponTypes = [AosWeaponTypes, WeaponTypes];
public static BaseWeapon RandomWeapon(bool inTokuno = false, bool isMondain = false)
public static BaseWeapon RandomWeapon(bool inTokuno = false, bool isMondain = false, bool isStygian = false)
{
if (Core.SA && isStygian)
{
return Construct<BaseWeapon>(_saWeaponTypes);
}
if (Core.ML && isMondain)
{
return Construct<BaseWeapon>(_mlWeaponTypes);
@ -422,13 +445,19 @@ namespace Server
return Construct<BaseWeapon>(WeaponTypes);
}
private static readonly Type[][] _saWeaponOrJewelryTypes = [SAWeaponTypes, AosWeaponTypes, WeaponTypes, JewelryTypes];
private static readonly Type[][] _mlWeaponOrJewelryTypes = [MLWeaponTypes, AosWeaponTypes, WeaponTypes, JewelryTypes];
private static readonly Type[][] _seWeaponOrJewelryTypes = [SEWeaponTypes, AosWeaponTypes, WeaponTypes, JewelryTypes];
private static readonly Type[][] _aosWeaponOrJewelryTypes = [AosWeaponTypes, WeaponTypes, JewelryTypes];
private static readonly Type[][] _oldWeaponOrJewelryTypes = [WeaponTypes, JewelryTypes];
public static Item RandomWeaponOrJewelry(bool inTokuno = false, bool isMondain = false)
public static Item RandomWeaponOrJewelry(bool inTokuno = false, bool isMondain = false, bool isStygian = false)
{
if (Core.SA && isStygian)
{
return Construct(_saWeaponOrJewelryTypes);
}
if (Core.ML && isMondain)
{
return Construct(_mlWeaponOrJewelryTypes);
@ -576,6 +605,12 @@ namespace Server
return Construct(_oldArmorOrHatOrShieldOrJewelryTypes);
}
private static readonly Type[][] _saWeaponOrRangedOrArmorOrHatOrShieldTypes =
[
SAWeaponTypes, AosWeaponTypes, WeaponTypes, SARangedWeaponTypes, AosRangedWeaponTypes, RangedWeaponTypes,
ArmorTypes, AosHatTypes, HatTypes, AosShieldTypes, ShieldTypes
];
private static readonly Type[][] _mlWeaponOrRangedOrArmorOrHatOrShieldTypes =
[
MLWeaponTypes, AosWeaponTypes, WeaponTypes, MLRangedWeaponTypes, AosRangedWeaponTypes, RangedWeaponTypes,
@ -599,8 +634,13 @@ namespace Server
WeaponTypes, RangedWeaponTypes, ArmorTypes, HatTypes, ShieldTypes
];
public static Item RandomArmorOrShieldOrWeapon(bool inTokuno = false, bool isMondain = false)
public static Item RandomArmorOrShieldOrWeapon(bool inTokuno = false, bool isMondain = false, bool isStygian = false)
{
if (Core.SA && isStygian)
{
return Construct(_saWeaponOrRangedOrArmorOrHatOrShieldTypes);
}
if (Core.ML && isMondain)
{
return Construct(_mlWeaponOrRangedOrArmorOrHatOrShieldTypes);
@ -619,6 +659,12 @@ namespace Server
return Construct(_oldWeaponOrRangedOrArmorOrHatOrShieldTypes);
}
private static readonly Type[][] _saWeaponOrRangedOrArmorOrHatOrShieldOrJewelryTypes =
[
SAWeaponTypes, AosWeaponTypes, WeaponTypes, SARangedWeaponTypes, AosRangedWeaponTypes, RangedWeaponTypes,
ArmorTypes, AosHatTypes, HatTypes, AosShieldTypes, ShieldTypes, JewelryTypes
];
private static readonly Type[][] _mlWeaponOrRangedOrArmorOrHatOrShieldOrJewelryTypes =
[
MLWeaponTypes, AosWeaponTypes, WeaponTypes, MLRangedWeaponTypes, AosRangedWeaponTypes, RangedWeaponTypes,
@ -642,8 +688,13 @@ namespace Server
WeaponTypes, RangedWeaponTypes, ArmorTypes, HatTypes, ShieldTypes, JewelryTypes
];
public static Item RandomArmorOrShieldOrWeaponOrJewelry(bool inTokuno = false, bool isMondain = false)
public static Item RandomArmorOrShieldOrWeaponOrJewelry(bool inTokuno = false, bool isMondain = false, bool isStygian = false)
{
if (Core.SA && isStygian)
{
return Construct(_saWeaponOrRangedOrArmorOrHatOrShieldOrJewelryTypes);
}
if (Core.ML && isMondain)
{
return Construct(_mlWeaponOrRangedOrArmorOrHatOrShieldOrJewelryTypes);

View file

@ -0,0 +1,37 @@
using ModernUO.Serialization;
using System.Collections.Generic;
using Server.Items;
namespace Server.Mobiles;
[SerializationGenerator(0, false)]
public partial class Bladeweaver : BaseVendor
{
private readonly List<SBInfo> m_SBInfos = new();
[Constructible]
public Bladeweaver() : base("the bladeweaver")
{
SetSkill(SkillName.Throwing, 85.0, 100.0);
SetSkill(SkillName.Tactics, 85.0, 100.0);
}
protected override List<SBInfo> SBInfos => m_SBInfos;
public override NpcGuild NpcGuild => NpcGuild.WarriorsGuild;
public override void InitSBInfo()
{
m_SBInfos.Add(new SBBladeweaverWeapon());
}
public override void InitOutfit()
{
AddItem(new GargishLeatherArmsType1());
AddItem(new GargishLeatherChestType1());
AddItem(new GargishLeatherLegsType1());
AddItem(new Boomerang());
PackGold(100, 200);
}
}

View file

@ -0,0 +1,31 @@
using System.Collections.Generic;
using Server.Items;
namespace Server.Mobiles;
public class SBBladeweaverWeapon : SBInfo
{
public override IShopSellInfo SellInfo { get; } = new InternalSellInfo();
public override List<GenericBuyInfo> BuyInfo { get; } = new InternalBuyInfo();
public class InternalBuyInfo : List<GenericBuyInfo>
{
public InternalBuyInfo()
{
Add(new GenericBuyInfo(typeof(Boomerang), 250, 20, 0x8FF, 0));
Add(new GenericBuyInfo(typeof(Cyclone), 350, 20, 0x901, 0));
Add(new GenericBuyInfo(typeof(SoulGlaive), 500, 20, 0x090A, 0));
}
}
public class InternalSellInfo : GenericSellInfo
{
public InternalSellInfo()
{
Add(typeof(Boomerang), 125);
Add(typeof(Cyclone), 175);
Add(typeof(SoulGlaive), 250);
}
}
}