From c83fb6fe1d8ebecab5a7d1c7a2cb8258315070c3 Mon Sep 17 00:00:00 2001 From: Crome696 Date: Fri, 10 Jul 2026 16:01:15 +0200 Subject: [PATCH] feat: implement Rising Colossus --- .../Mysticism/RisingColossusSpellTests.cs | 381 ++++++++++++++++++ .../Server.Mobiles.RisingColossus.v0.json | 14 + .../Monsters/Summons/RisingColossus.cs | 87 ++++ Projects/UOContent/Spells/Initializer.cs | 2 +- .../Spells/Mysticism/RisingColossusSpell.cs | 96 +++++ 5 files changed, 579 insertions(+), 1 deletion(-) create mode 100644 Projects/UOContent.Tests/Tests/Spells/Mysticism/RisingColossusSpellTests.cs create mode 100644 Projects/UOContent/Migrations/Server.Mobiles.RisingColossus.v0.json create mode 100644 Projects/UOContent/Mobiles/Monsters/Summons/RisingColossus.cs create mode 100644 Projects/UOContent/Spells/Mysticism/RisingColossusSpell.cs diff --git a/Projects/UOContent.Tests/Tests/Spells/Mysticism/RisingColossusSpellTests.cs b/Projects/UOContent.Tests/Tests/Spells/Mysticism/RisingColossusSpellTests.cs new file mode 100644 index 000000000..ee51d4c08 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Mysticism/RisingColossusSpellTests.cs @@ -0,0 +1,381 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using Server.Items; +using Server.Mobiles; +using Server.Spells; +using Server.Spells.Mysticism; +using Xunit; + +namespace Server.Tests.Spells.Mysticism; + +[Collection("Sequential UOContent Tests")] +public class RisingColossusSpellTests +{ + static RisingColossusSpellTests() + { + NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry + { + Level = SpeedLevel.Medium, + ActiveSpeed = 0.25, + PassiveSpeed = 0.5, + Types = [typeof(RisingColossus)] + }); + } + + [Fact] + public void SpellMetadata_MatchesRisingColossusSources() + { + var caster = NewCaster(); + var spell = new RisingColossusSpell(caster); + + Assert.Equal("Rising Colossus", spell.Name); + Assert.Equal("Kal Vas Xen Corp Ylem", spell.Mantra); + Assert.Equal(SpellCircle.Eighth, spell.Circle); + Assert.Equal(TimeSpan.FromSeconds(2.25), spell.CastDelayBase); + Assert.Equal(50, spell.GetMana()); + Assert.Equal(83.0, spell.RequiredSkill); + Assert.Equal(SkillName.Mysticism, spell.CastSkill); + Assert.Equal( + [Reagent.DaemonBone, Reagent.DragonsBlood, Reagent.FertileDirt, Reagent.Nightshade], + spell.Reagents + ); + + caster.Delete(); + } + + [Fact] + public void MysticSpellbookAndScroll_UseRisingColossusSlot() + { + var book = new MysticSpellbook(1UL << (692 - 677)); + var scroll = new RisingColossusScroll(); + + Assert.Equal(677, book.BookOffset); + Assert.Equal(16, book.BookCount); + Assert.True(book.HasSpell(692)); + Assert.Equal(692, GetSpellScrollId(scroll)); + + book.Delete(); + scroll.Delete(); + } + + [Fact] + public void RegisterMysticism_PreSaDoesNotExposeRisingColossus() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.ML; + Initializer.Configure(); + + Assert.Null(SpellRegistry.NewSpell(692, caster, null)); + Assert.Equal(-1, SpellRegistry.GetRegistryNumber(typeof(RisingColossusSpell))); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Fact] + public void RegisterMysticism_SaExposesRisingColossusAtSpellId692() + { + var previousExpansion = Core.Expansion; + var caster = NewCaster(); + + try + { + ResetSpellRegistry(); + Core.Expansion = Expansion.SA; + Initializer.Configure(); + + Assert.Same(typeof(RisingColossusSpell), SpellRegistry.Types[692]); + Assert.Equal(692, SpellRegistry.GetRegistryNumber(typeof(RisingColossusSpell))); + Assert.IsType(SpellRegistry.NewSpell(692, caster, null)); + } + finally + { + Core.Expansion = previousExpansion; + ResetSpellRegistry(); + Initializer.Configure(); + caster.Delete(); + } + } + + [Theory] + [InlineData(83.0, 0.0, 27.6666666667)] + [InlineData(120.0, 0.0, 40.0)] + [InlineData(120.0, 120.0, 60.0)] + public void Duration_UsesMysticismAndHigherSupportSkill(double mysticism, double supportSkill, double expectedSeconds) + { + Assert.Equal(TimeSpan.FromSeconds(expectedSeconds), RisingColossusSpell.GetDuration(mysticism, supportSkill)); + } + + [Fact] + public void Summon_UsesFiveFollowerSlotsAndSkillScaledPower() + { + var caster = NewCaster(mysticism: 120.0, focus: 120.0); + var summon = new RisingColossus(caster, 120.0, 120.0); + + Assert.Equal(5, summon.ControlSlots); + Assert.Equal(780, summon.Str); + Assert.Equal(210, summon.Dex); + Assert.Equal(230, summon.Int); + Assert.Equal(469, summon.HitsMax); + Assert.Equal(20, summon.DamageMin); + Assert.Equal(24, summon.DamageMax); + Assert.Equal(120.0, summon.Skills.MagicResist.Value); + Assert.Equal(120.0, summon.Skills.Tactics.Value); + Assert.Equal(120.0, summon.Skills.Wrestling.Value); + Assert.Equal(98.5, summon.DispelDifficulty); + Assert.Equal(45.0, summon.DispelFocus); + Assert.True(summon.BleedImmune); + Assert.Equal(Poison.Lethal, summon.PoisonImmune); + Assert.True(summon.AlwaysMurderer); + + summon.Delete(); + caster.Delete(); + } + + [Fact] + public void CheckCast_RejectsInsufficientSkillManaAndFollowerCapacity() + { + var lowSkillCaster = NewCaster(mysticism: 82.9); + var lowSkillSpell = new RisingColossusSpell(lowSkillCaster); + Assert.False(lowSkillSpell.CheckCast()); + lowSkillCaster.Delete(); + + var lowManaCaster = NewCaster(); + lowManaCaster.Mana = 0; + var lowManaSpell = new RisingColossusSpell(lowManaCaster); + Assert.False(lowManaSpell.CheckCast()); + lowManaCaster.Delete(); + + var fullFollowerCaster = NewCaster(); + fullFollowerCaster.Followers = fullFollowerCaster.FollowersMax - 4; + var fullFollowerSpell = new RisingColossusSpell(fullFollowerCaster); + Assert.False(fullFollowerSpell.CheckCast()); + fullFollowerCaster.Delete(); + } + + [Fact] + public void TargetSelection_RanksIntelligentTargetsByProximity() + { + var caster = NewCaster(); + var summon = new RisingColossus(caster, 120.0, 120.0); + var nearby = NewMobile(new Point3D(6201, 500, 0)); + var intelligent = NewMobile(new Point3D(6204, 500, 0)); + intelligent.Int = 120; + intelligent.Skills.Magery.Base = 120.0; + + Assert.True(summon.GetFightModeRanking(intelligent, FightMode.Closest, false) > + summon.GetFightModeRanking(nearby, FightMode.Closest, false)); + + intelligent.Delete(); + nearby.Delete(); + summon.Delete(); + caster.Delete(); + } + + [Fact] + public void TargetLocation_RejectsHouseLocations() + { + var caster = NewCaster(); + var outside = new Point3D(6200, 500, 0); + + Assert.False(RisingColossusSpell.IsHouseLocation(caster, outside, Map.Felucca)); + + caster.Delete(); + } + + [Fact] + public void SuccessfulTarget_ConsumesManaAndCanonicalReagentsAndCreatesSummon() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + AddReagents(caster); + var summonLocation = FindSpawnLocation(); + caster.MoveToWorld(new Point3D(summonLocation.X - 1, summonLocation.Y, summonLocation.Z), Map.Felucca); + var spell = new TestRisingColossusSpell(caster); + caster.Spell = spell; + spell.State = SpellState.Sequencing; + + try + { + Assert.True(SpellHelper.CheckTown(summonLocation, caster)); + spell.Target(summonLocation); + + var summon = FindSummon(caster); + Assert.Equal(50, caster.Mana); + Assert.Equal(5, caster.Followers); + Assert.NotNull(summon); + Assert.Equal(5, caster.Followers); + Assert.Equal(50, caster.Mana); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + Assert.Equal(9, caster.Backpack.FindItemByType().Amount); + + summon.Delete(); + Assert.Equal(0, caster.Followers); + } + finally + { + spell.FinishSequence(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + [Fact] + public void BlockedTarget_DoesNotConsumeResourcesOrCreateSummon() + { + var previousExpansion = Core.Expansion; + Core.Expansion = Expansion.SA; + + var caster = NewCaster(); + AddReagents(caster); + var summonLocation = FindSpawnLocation(); + var blockedLocation = FindBlockedLocation(); + caster.MoveToWorld(new Point3D(summonLocation.X - 1, summonLocation.Y, summonLocation.Z), Map.Felucca); + var spell = new TestRisingColossusSpell(caster); + caster.Spell = spell; + spell.State = SpellState.Sequencing; + + try + { + spell.Target(blockedLocation); + + Assert.Equal(100, caster.Mana); + Assert.Equal(0, caster.Followers); + Assert.Equal(10, caster.Backpack.FindItemByType().Amount); + Assert.Null(FindSummon(caster)); + } + finally + { + spell.FinishSequence(); + caster.Delete(); + Core.Expansion = previousExpansion; + } + } + + private static RisingColossus FindSummon(Mobile caster) + { + foreach (var mobile in caster.Map.GetMobilesInRange(caster.Location, 2)) + { + if (mobile is RisingColossus summon && summon.SummonMaster == caster) + { + return summon; + } + } + + return null; + } + + private static PlayerMobile NewCaster(double mysticism = 120.0, double focus = 120.0, double imbuing = 0.0) + { + var caster = new PlayerMobile(World.NewMobile); + caster.DefaultMobileInit(); + caster.Player = true; + caster.InitStats(100, 100, 100); + caster.Mana = caster.ManaMax; + caster.AddItem(new Backpack()); + caster.Skills.Mysticism.Base = mysticism; + caster.Skills.Focus.Base = focus; + caster.Skills.Imbuing.Base = imbuing; + return caster; + } + + private static Mobile NewMobile(Point3D location) + { + var mobile = new Mobile(World.NewMobile); + mobile.DefaultMobileInit(); + mobile.InitStats(100, 100, 100); + mobile.MoveToWorld(location, Map.Felucca); + return mobile; + } + + private static Point3D FindSpawnLocation() + { + for (var x = 6100; x < 6200; ++x) + { + for (var y = 400; y < 600; ++y) + { + var z = Map.Felucca.GetAverageZ(x, y); + if (Map.Felucca.CanSpawnMobile(x, y, z)) + { + return new Point3D(x, y, z); + } + } + } + + throw new InvalidOperationException("No valid Felucca summon location was found for the test fixture."); + } + + private static Point3D FindBlockedLocation() + { + for (var x = 6200; x < 6300; ++x) + { + for (var y = 400; y < 600; ++y) + { + var z = Map.Felucca.GetAverageZ(x, y); + if (!Map.Felucca.CanSpawnMobile(x, y, z)) + { + return new Point3D(x, y, z); + } + } + } + + throw new InvalidOperationException("No blocked Felucca summon location was found for the test fixture."); + } + + private static void AddReagents(Mobile caster) + { + caster.AddToBackpack(new DaemonBone(10)); + caster.AddToBackpack(new DragonsBlood(10)); + caster.AddToBackpack(new FertileDirt(10)); + caster.AddToBackpack(new Nightshade(10)); + } + + private static int GetSpellScrollId(SpellScroll scroll) + { + var field = typeof(SpellScroll).GetField("_spellID", BindingFlags.Instance | BindingFlags.NonPublic); + return (int)field!.GetValue(scroll)!; + } + + private static void ResetSpellRegistry() + { + var types = (Type[])typeof(SpellRegistry) + .GetField("m_Types", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + Array.Clear(types); + + var idsFromTypes = (Dictionary)typeof(SpellRegistry) + .GetField("m_IDsFromTypes", BindingFlags.Static | BindingFlags.NonPublic)! + .GetValue(null)!; + idsFromTypes.Clear(); + + typeof(SpellRegistry) + .GetField("m_Count", BindingFlags.Static | BindingFlags.NonPublic)! + .SetValue(null, 0); + + SpellRegistry.SpecialMoves.Clear(); + } + + private sealed class TestRisingColossusSpell : RisingColossusSpell + { + public TestRisingColossusSpell(Mobile caster) : base(caster) + { + } + + public override bool CheckFizzle() => true; + } +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.RisingColossus.v0.json b/Projects/UOContent/Migrations/Server.Mobiles.RisingColossus.v0.json new file mode 100644 index 000000000..9863f0460 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.RisingColossus.v0.json @@ -0,0 +1,14 @@ +{ + "version": 0, + "type": "Server.Mobiles.RisingColossus", + "properties": [ + { + "name": "DispelDifficulty", + "type": "double", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Monsters/Summons/RisingColossus.cs b/Projects/UOContent/Mobiles/Monsters/Summons/RisingColossus.cs new file mode 100644 index 000000000..cb651b553 --- /dev/null +++ b/Projects/UOContent/Mobiles/Monsters/Summons/RisingColossus.cs @@ -0,0 +1,87 @@ +using System; +using ModernUO.Serialization; +using Server.Items; + +namespace Server.Mobiles; + +[SerializationGenerator(0, false)] +public partial class RisingColossus : BaseCreature +{ + private double _dispelDifficulty; + + [Constructible] + public RisingColossus(Mobile caster, double mysticism, double supportSkill) : + base(AIType.AI_Melee, FightMode.Closest, 10, 1) + { + var level = (int)(mysticism + supportSkill); + var statBonus = (int)((mysticism - 83.0) / 1.3 + (supportSkill - 30.0) / 1.3 + 6.0); + var hitsBonus = (int)((mysticism - 83.0) * 1.14 + (supportSkill - 30.0) * 1.03 + 20.0); + var skillValue = supportSkill != 0.0 ? (mysticism + supportSkill) / 2.0 : (mysticism + 20.0) / 2.0; + + Body = 829; + + SetStr(677 + statBonus); + SetDex(107 + statBonus); + SetInt(127 + statBonus); + + SetHits(315 + hitsBonus); + SetStam(250); + SetMana(0); + SetDamage(level / 12, level / 10); + + SetDamageType(ResistanceType.Physical, 100); + + SetResistance(ResistanceType.Physical, 65, 70); + SetResistance(ResistanceType.Fire, 50, 55); + SetResistance(ResistanceType.Cold, 50, 55); + SetResistance(ResistanceType.Poison, 100); + SetResistance(ResistanceType.Energy, 65, 70); + + SetSkill(SkillName.MagicResist, skillValue); + SetSkill(SkillName.Tactics, skillValue); + SetSkill(SkillName.Wrestling, skillValue); + SetSkill(SkillName.Anatomy, skillValue); + SetSkill(SkillName.EvalInt, skillValue); + SetSkill(SkillName.DetectHidden, 70.0); + SetSkill(SkillName.Mysticism, caster.Skills.Mysticism.Value); + SetSkill(SkillName.Focus, caster.Skills.Focus.Value); + SetSkill(SkillName.Imbuing, caster.Skills.Imbuing.Value); + + Fame = 0; + Karma = 0; + VirtualArmor = 60; + ControlSlots = 5; + + // UO.com and ServUO document dispel counterplay but do not publish a + // current numeric difficulty formula. Keep it in the normal 80-100 + // spell-skill range instead of inheriting ServUO's incompatible legacy + // expression, which produces values above the current 0-200 scale. + _dispelDifficulty = Math.Clamp(80.0 + (mysticism - 83.0) * 0.5, 80.0, 100.0); + } + + public RisingColossus(Serial serial) : base(serial) + { + } + + [SerializableProperty(0, useField: nameof(_dispelDifficulty))] + public override double DispelDifficulty => _dispelDifficulty; + + public override double DispelFocus => 45.0; + public override string DefaultName => "a rising colossus"; + public override string CorpseName => "a rising colossus corpse"; + public override bool DeleteCorpseOnDeath => Summoned; + public override bool AlwaysMurderer => true; + public override bool BleedImmune => true; + public override Poison PoisonImmune => Poison.Lethal; + public override bool FollowsAcquireRules => + Core.AOS || !Summoned || SummonMaster?.Player != true || Map != Map.Felucca; + + public override double GetFightModeRanking(Mobile mobile, FightMode acqType, bool bPlayerOnly) => + (mobile.Int + mobile.Skills.Magery.Value) / Math.Max(this.GetDistanceToSqrt(mobile), 1.0); + + public override WeaponAbility GetWeaponAbility() => + Utility.RandomBool() ? WeaponAbility.ArmorIgnore : WeaponAbility.CrushingBlow; + + public override int GetAttackSound() => 0x627; + public override int GetHurtSound() => 0x629; +} \ No newline at end of file diff --git a/Projects/UOContent/Spells/Initializer.cs b/Projects/UOContent/Spells/Initializer.cs index a1cf11ea4..3ff29ab8a 100644 --- a/Projects/UOContent/Spells/Initializer.cs +++ b/Projects/UOContent/Spells/Initializer.cs @@ -196,7 +196,7 @@ namespace Server.Spells Register(689, typeof(SpellPlagueSpell)); Register(690, typeof(HailStormSpell)); Register(691, typeof(NetherCycloneSpell)); - // Register(692, typeof(RisingColossusSpell)); + Register(692, typeof(RisingColossusSpell)); } } } diff --git a/Projects/UOContent/Spells/Mysticism/RisingColossusSpell.cs b/Projects/UOContent/Spells/Mysticism/RisingColossusSpell.cs new file mode 100644 index 000000000..833358d61 --- /dev/null +++ b/Projects/UOContent/Spells/Mysticism/RisingColossusSpell.cs @@ -0,0 +1,96 @@ +using System; +using Server.Items; +using Server.Mobiles; +using Server.Regions; + +namespace Server.Spells.Mysticism; + +public class RisingColossusSpell : MysticSpell, ITargetingSpell +{ + private const int ControlSlotCost = 5; + + private static readonly SpellInfo _info = new( + "Rising Colossus", + "Kal Vas Xen Corp Ylem", + 230, + 9022, + Reagent.DaemonBone, + Reagent.DragonsBlood, + Reagent.FertileDirt, + Reagent.Nightshade + ); + + public RisingColossusSpell(Mobile caster, Item scroll = null) : base(caster, scroll, _info) + { + } + + public override SpellCircle Circle => SpellCircle.Eighth; + + internal static TimeSpan GetDuration(double mysticism, double supportSkill) + { + // Public sources describe a duration of up to about 60 seconds. Capping the + // combined-skill formula at that value avoids importing ServUO's longer + // 120/120 duration as an undocumented RebirthUO balance decision. + return TimeSpan.FromSeconds(Math.Min(60.0, Math.Max(0.0, mysticism + supportSkill) / 3.0)); + } + + internal static bool IsHouseLocation(Mobile caster, Point3D location, Map map) + { + if (Region.Find(location, map).IsPartOf()) + { + return true; + } + + return caster.Map != null && Region.Find(caster.Location, caster.Map).IsPartOf(); + } + + public override bool CheckCast() + { + if (!base.CheckCast()) + { + return false; + } + + if (Caster.Followers + ControlSlotCost > Caster.FollowersMax) + { + Caster.SendLocalizedMessage(1049645); // You have too many followers to summon that creature. + return false; + } + + return true; + } + + public void Target(IPoint3D point) + { + if (Caster.Followers + ControlSlotCost > Caster.FollowersMax) + { + Caster.SendLocalizedMessage(1049645); // You have too many followers to summon that creature. + return; + } + + var map = Caster.Map; + SpellHelper.GetSurfaceTop(ref point); + + if (map?.CanSpawnMobile(point.X, point.Y, point.Z) != true || IsHouseLocation(Caster, new Point3D(point), map)) + { + Caster.SendLocalizedMessage(501942); // That location is blocked. + } + else if (SpellHelper.CheckTown(point, Caster) && CheckSequence()) + { + var mysticism = GetBaseSkill(Caster); + var supportSkill = GetDamageSkill(Caster); + var duration = GetDuration(mysticism, supportSkill); + var summon = new RisingColossus(Caster, mysticism, supportSkill); + + if (BaseCreature.Summon(summon, false, Caster, new Point3D(point), 0x656, duration)) + { + Effects.SendTargetParticles(summon, 0x3728, 10, 10, 0x13AA, (EffectLayer)255); + } + } + } + + public override void OnCast() + { + Caster.Target = new SpellTarget(this, allowGround: true, retryOnLos: true); + } +} \ No newline at end of file