diff --git a/Projects/UOContent.Tests/Tests/Spells/Ninjitsu/AnimalFormTests.cs b/Projects/UOContent.Tests/Tests/Spells/Ninjitsu/AnimalFormTests.cs new file mode 100644 index 000000000..34a665495 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Spells/Ninjitsu/AnimalFormTests.cs @@ -0,0 +1,88 @@ +using System; +using Server.Mobiles; +using Server.Spells.Ninjitsu; +using Xunit; + +namespace Server.Tests.Spells.Ninjitsu; + +[Collection("Sequential UOContent Tests")] +public class AnimalFormTests +{ + private static int IndexOf(Type type) => Array.FindIndex(AnimalForm.Entries, e => e.Type == type); + + private static Mobile NewMobileWithNinjitsu(double skill) + { + var m = new Mobile(World.NewMobile); + m.DefaultMobileInit(); + m.Skills.Ninjitsu.Base = skill; + return m; + } + + // Issue #2452: with only 30 Ninjitsu the gump showed every form (including Dog, which needs 40) + // because the selectable check compared Skill.Fixed (value x10) against the raw ReqSkill. + [Fact] + public void CanSelectEntry_FormAboveSkill_IsNotSelectable() + { + var m = NewMobileWithNinjitsu(30.0); + + Assert.False(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Dog))])); // ReqSkill 40 + Assert.False(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Cat))])); // ReqSkill 40 + + m.Delete(); + } + + [Fact] + public void CanSelectEntry_FormAtOrBelowSkill_IsSelectable() + { + var m = NewMobileWithNinjitsu(40.0); + + Assert.True(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Dog))])); // ReqSkill 40 (== boundary) + Assert.True(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Rat))])); // ReqSkill 20 + Assert.True(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Rabbit))])); // ReqSkill 20 + + m.Delete(); + } + + // Talisman-gated forms are never selectable without the matching talisman, regardless of skill. + [Fact] + public void CanSelectEntry_TalismanGatedForm_RequiresTalisman() + { + var m = NewMobileWithNinjitsu(120.0); + + Assert.False(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Squirrel))])); + Assert.False(AnimalForm.CanSelectEntry(m, AnimalForm.Entries[IndexOf(typeof(Ferret))])); + + m.Delete(); + } + + // Issue #2452: attempting a form above your skill must report NoSkill (so the caller can + // refrain from charging mana) and must not actually transform the caster. + [Fact] + public void Morph_InsufficientSkill_ReturnsNoSkillAndDoesNotTransform() + { + var m = NewMobileWithNinjitsu(30.0); + + var result = AnimalForm.Morph(m, IndexOf(typeof(Dog))); // needs 40 + + Assert.Equal(AnimalForm.MorphResult.NoSkill, result); + Assert.Null(AnimalForm.GetContext(m)); + Assert.Equal(0, (int)m.BodyMod); + + m.Delete(); + } + + [Fact] + public void Morph_SufficientSkill_TransformsCaster() + { + // >= ReqSkill + 37.5 guarantees the success-chance roll is skipped. + var m = NewMobileWithNinjitsu(60.0); + + var result = AnimalForm.Morph(m, IndexOf(typeof(Rat))); // needs 20 + + Assert.Equal(AnimalForm.MorphResult.Success, result); + Assert.NotNull(AnimalForm.GetContext(m)); + + AnimalForm.RemoveContext(m); // stop the form timer / clear state + m.Delete(); + } +} diff --git a/Projects/UOContent/Spells/Ninjitsu/AnimalForm.cs b/Projects/UOContent/Spells/Ninjitsu/AnimalForm.cs index f7722cfef..9e924d1a3 100644 --- a/Projects/UOContent/Spells/Ninjitsu/AnimalForm.cs +++ b/Projects/UOContent/Spells/Ninjitsu/AnimalForm.cs @@ -140,22 +140,25 @@ public class AnimalForm : NinjaSpell RemoveContext(Caster, context); Caster.Mana -= mana; } + // On OSI, casting while standing still always opens the selection menu; casting while + // moving quick-transforms into the last selected form without the menu. + else if (Caster is PlayerMobile && !_wasMoving && !CasterIsMoving()) + { + Caster.SendGump(new AnimalFormGump(Caster, Entries, this)); + } else { - var lastAnimalForm = GetLastAnimalForm(Caster); - if (Caster is PlayerMobile && lastAnimalForm == -1 && !_wasMoving && !CasterIsMoving()) - { - Caster.SendGump(new AnimalFormGump(Caster, Entries, this)); - } - else if (Morph(Caster, lastAnimalForm) == MorphResult.Fail) - { - DoFizzle(); - } - else + var result = Morph(Caster, GetLastAnimalForm(Caster)); + if (result == MorphResult.Success) { Caster.FixedParticles(0x3728, 10, 13, 2023, EffectLayer.Waist); Caster.Mana -= mana; } + else if (result == MorphResult.Fail) + { + DoFizzle(); + } + // MorphResult.NoSkill: Morph already messaged the requirement; consume no mana. } } @@ -164,6 +167,11 @@ public class AnimalForm : NinjaSpell public static int GetLastAnimalForm(Mobile m) => _lastAnimalForms.GetValueOrDefault(m, -1); + // Whether the caster currently meets the requirements (Ninjitsu skill + talisman) to select this form. + // Note: ReqSkill is on the 0-100 scale, so this must compare against Skill.Value, not Skill.Fixed (value x10). + public static bool CanSelectEntry(Mobile m, AnimalFormEntry entry) => + m.Skills.Ninjitsu.Value >= entry.ReqSkill && BaseFormTalisman.EntryEnabled(m, entry.Type); + public static MorphResult Morph(Mobile m, int entryID) { if (entryID < 0 || entryID >= Entries.Length) @@ -386,12 +394,11 @@ public class AnimalForm : NinjaSpell builder.AddButton(10, 374, 0xFB1, 0xFB2, 0); builder.AddHtmlLocalized(45, 376, 450, 20, 1011012, 0x7FFF); // CANCEL - var ninjitsu = _caster.Skills[SkillName.Ninjitsu].Fixed; var current = 0; for (var i = 0; i < _entries.Length; ++i) { - var enabled = ninjitsu >= _entries[i].ReqSkill && BaseFormTalisman.EntryEnabled(_caster, _entries[i].Type); + var enabled = CanSelectEntry(_caster, _entries[i]); var page = current / 10 + 1; var pos = current % 10; @@ -461,16 +468,21 @@ public class AnimalForm : NinjaSpell { _caster.SendLocalizedMessage(1063108); // You cannot use this ability right now. } - else if (Morph(_caster, entryID) == MorphResult.Fail) - { - _caster.LocalOverheadMessage(MessageType.Regular, 0x3B2, 502632); // The spell fizzles. - _caster.FixedParticles(0x3735, 1, 30, 9503, EffectLayer.Waist); - _caster.PlaySound(0x5C); - } else { - _caster.FixedParticles(0x3728, 10, 13, 2023, EffectLayer.Waist); - _caster.Mana -= mana; + var result = Morph(_caster, entryID); + if (result == MorphResult.Success) + { + _caster.FixedParticles(0x3728, 10, 13, 2023, EffectLayer.Waist); + _caster.Mana -= mana; + } + else if (result == MorphResult.Fail) + { + _caster.LocalOverheadMessage(MessageType.Regular, 0x3B2, 502632); // The spell fizzles. + _caster.FixedParticles(0x3735, 1, 30, 9503, EffectLayer.Waist); + _caster.PlaySound(0x5C); + } + // MorphResult.NoSkill: Morph already messaged the requirement; consume no mana. } } }