feat: implement Rising Colossus

This commit is contained in:
Crome696 2026-07-10 16:01:15 +02:00
parent 0f2d83eb3d
commit c83fb6fe1d
5 changed files with 579 additions and 1 deletions

View file

@ -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<RisingColossusSpell>(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<DaemonBone>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<DragonsBlood>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<FertileDirt>().Amount);
Assert.Equal(9, caster.Backpack.FindItemByType<Nightshade>().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<DaemonBone>().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<Type, int>)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;
}
}

View file

@ -0,0 +1,14 @@
{
"version": 0,
"type": "Server.Mobiles.RisingColossus",
"properties": [
{
"name": "DispelDifficulty",
"type": "double",
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
}
]
}

View file

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

View file

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

View file

@ -0,0 +1,96 @@
using System;
using Server.Items;
using Server.Mobiles;
using Server.Regions;
namespace Server.Spells.Mysticism;
public class RisingColossusSpell : MysticSpell, ITargetingSpell<IPoint3D>
{
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<HouseRegion>())
{
return true;
}
return caster.Map != null && Region.Find(caster.Location, caster.Map).IsPartOf<HouseRegion>();
}
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<IPoint3D>(this, allowGround: true, retryOnLos: true);
}
}