diff --git a/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs b/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs new file mode 100644 index 000000000..07abaa538 --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Engines/CannedEvil/ChampionMinionTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Server; +using Server.Engines.CannedEvil; +using Server.Mobiles; +using Xunit; + +namespace UOContent.Tests.Engines.CannedEvil; + +// Minions summoned by a champion belong to its spawn, so clearing the spawn removes them instead of +// leaving them behind for good. +[Collection("Sequential UOContent Tests")] +public class ChampionMinionTests : IDisposable +{ + private static readonly Point3D _location = new(1500, 1500, 0); + + private readonly List _created = []; + + public void Dispose() + { + foreach (var m in World.Mobiles.Values.OfType().ToList()) + { + m.Delete(); + } + + for (var i = 0; i < _created.Count; i++) + { + _created[i].Delete(); + } + } + + private ChampionSpawn NewSpawn() + { + var spawn = new ChampionSpawn(); + _created.Add(spawn); + spawn.MoveToWorld(_location, Map.Felucca); + spawn.SpawnArea = new Rectangle2D(new Point2D(_location.X - 24, _location.Y - 24), new Point2D(_location.X + 24, _location.Y + 24)); + return spawn; + } + + private LordOaks NewOaks() + { + var oaks = new LordOaks(); + _created.Add(oaks); + oaks.MoveToWorld(_location, Map.Felucca); + return oaks; + } + + private Mobile NewAttacker() + { + var attacker = new Mongbat(); + _created.Add(attacker); + return attacker; + } + + [Fact] + public void AChampionsMinion_IsDeletedWithTheSpawnsCreatures() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); + spawn.Champion = oaks; + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + Assert.Contains(queen, spawn.Minions); + + spawn.DeleteCreatures(); + + Assert.True(queen.Deleted); + Assert.Empty(spawn.Minions); + } + + [Fact] + public void AMinionOfAMinion_AlsoBelongsToTheSpawn() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); + spawn.Champion = oaks; + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + var pixie = new Pixie(); + _created.Add(pixie); + Assert.True(queen.SpawnMinion(pixie)); + + Assert.Contains(pixie, spawn.Minions); + } + + [Fact] + public void ACreatureOutsideAnySpawn_SummonsAStandaloneMinion() + { + var spawn = NewSpawn(); + var oaks = NewOaks(); // in the spawn's region, but not its champion + + oaks.AggressiveAction(NewAttacker(), false); + var queen = World.Mobiles.Values.OfType().Single(); + + spawn.DeleteCreatures(); + + Assert.False(queen.Deleted); + Assert.Null(ChampionSpawn.FindSpawnOf(oaks)); + } +} diff --git a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs index 3a024576b..a5d457e2f 100755 --- a/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs +++ b/Projects/UOContent/Engines/CannedEvil/ChampionSpawn.cs @@ -28,7 +28,7 @@ using Server.Logging; namespace Server.Engines.CannedEvil; -[SerializationGenerator(11, false)] +[SerializationGenerator(12, false)] public partial class ChampionSpawn : Item { private void MigrateFrom(V10Content content) @@ -59,6 +59,35 @@ public partial class ChampionSpawn : Item _restartTime = content.RestartTime; } + private void MigrateFrom(V11Content content) + { + _level = content.Level; + _activatedByProximity = content.ActivatedByProximity; + _nextProximityTime = content.NextProximityTime; + _maxLevel = content.MaxLevel; + _activatedByValor = content.ActivatedByValor; + _damageEntries = content.DamageEntries; + _confinedRoaming = content.ConfinedRoaming; + _idol = content.Idol; + _hasBeenAdvanced = content.HasBeenAdvanced; + _spawnArea = content.SpawnArea; + _randomizeType = content.RandomizeType; + _kills = content.Kills; + _active = content.Active; + _type = content.Type; + _creatures = content.Creatures; + _redSkulls = content.RedSkulls; + _whiteSkulls = content.WhiteSkulls; + _platform = content.Platform; + _altar = content.Altar; + _expireDelay = content.ExpireDelay; + _expireTime = content.ExpireTime; + _champion = content.Champion; + _restartDelay = content.RestartDelay; + _restartTime = content.RestartTime; + _minions = []; + } + private static readonly ILogger logger = LogFactory.GetLogger(typeof(ChampionSpawn)); [SerializableField(1)] @@ -143,6 +172,12 @@ public partial class ChampionSpawn : Item [SerializedCommandProperty(AccessLevel.GameMaster)] private DateTime _restartTime; + // Mobiles summoned by the champion or the spawn's creatures. Deleted with the creatures, never counted as kills. + [Tidy] + [SerializableField(24)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private List _minions; + private ChampionSpawnRegion m_Region; //Goes back each level, below level 0 and it goes off! @@ -173,6 +208,7 @@ public partial class ChampionSpawn : Item Visible = false; _creatures = new List(); + _minions = []; _redSkulls = new List(); _whiteSkulls = new List(); @@ -294,6 +330,21 @@ public partial class ChampionSpawn : Item public bool IsChampionSpawn(Mobile m) => _creatures.Contains(m); + /// + /// The spawn fights for: as its champion, one of its creatures, or a minion of either. + /// + public static ChampionSpawn FindSpawnOf(BaseCreature creature) + { + var spawn = Region.Find(creature.Location, creature.Map).GetRegion()?.Spawn; + + return spawn?.Deleted == false && + (spawn.Champion == creature || spawn._minions.Contains(creature) || spawn._creatures.Contains(creature)) + ? spawn + : null; + } + + public void AddMinion(Mobile minion) => AddToMinions(minion); + [CommandProperty(AccessLevel.GameMaster)] public virtual int MaxKills { @@ -1171,6 +1222,13 @@ public partial class ChampionSpawn : Item ClearCreatures(); } + + for (var i = 0; i < _minions.Count; ++i) + { + _minions[i].Delete(); + } + + ClearMinions(); } public virtual void RegisterDamageTo(Mobile m) @@ -1321,6 +1379,7 @@ public partial class ChampionSpawn : Item _active = reader.ReadBool(); _type = (ChampionSpawnType)reader.ReadInt(); _creatures = reader.ReadEntityList(); + _minions = []; _redSkulls = reader.ReadEntityList(); _whiteSkulls = reader.ReadEntityList(); _platform = reader.ReadEntity(); diff --git a/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json new file mode 100644 index 000000000..55d3c3ec8 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json @@ -0,0 +1,199 @@ +{ + "version": 12, + "type": "Server.Engines.CannedEvil.ChampionSpawn", + "properties": [ + { + "name": "Level", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByProximity", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "NextProximityTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "MaxLevel", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "ActivatedByValor", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "DamageEntries", + "type": "System.Collections.Generic.Dictionary\u003CServer.Mobile, int\u003E", + "rule": "DictionaryMigrationRule", + "ruleArguments": [ + "Server.Mobile", + "SerializableInterfaceMigrationRule", + "0", + "int", + "PrimitiveTypeMigrationRule", + "1", + "" + ] + }, + { + "name": "ConfinedRoaming", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Idol", + "type": "Server.Engines.CannedEvil.IdolOfTheChampion", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "HasBeenAdvanced", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "SpawnArea", + "type": "Server.Rectangle2D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Rect2D" + ] + }, + { + "name": "RandomizeType", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Kills", + "type": "int", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Active", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "Type", + "type": "Server.Engines.CannedEvil.ChampionSpawnType", + "rule": "EnumMigrationRule" + }, + { + "name": "Creatures", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "RedSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "WhiteSkulls", + "type": "System.Collections.Generic.List\u003CServer.Item\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Item", + "SerializableInterfaceMigrationRule" + ] + }, + { + "name": "Platform", + "type": "Server.Engines.CannedEvil.ChampionPlatform", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "Altar", + "type": "Server.Engines.CannedEvil.ChampionAltar", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "ExpireDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "ExpireTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Champion", + "type": "Server.Mobile", + "rule": "SerializableInterfaceMigrationRule" + }, + { + "name": "RestartDelay", + "type": "System.TimeSpan", + "rule": "PrimitiveTypeMigrationRule" + }, + { + "name": "RestartTime", + "type": "System.DateTime", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "AnchoredTime" + ] + }, + { + "name": "Minions", + "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E", + "rule": "ListMigrationRule", + "ruleArguments": [ + "@Tidy", + "Server.Mobile", + "SerializableInterfaceMigrationRule" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs b/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs index 3fe6ba737..41b2f9185 100644 --- a/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs +++ b/Projects/UOContent/Mobiles/Abilities/SummonPixiesCounter.cs @@ -11,9 +11,10 @@ public class SummonPixiesCounter : MonsterAbility var amount = Utility.RandomMinMax(3, 6); for (var i = 0; i < amount; i++) { - var pixie = new Pixie { Team = source.Team, FightMode = FightMode.Closest }; - pixie.MoveToWorld(source.Map.GetRandomNearbyLocation(source.Location), source.Map); - pixie.Combatant = target; + if (!source.SpawnMinion(new Pixie { FightMode = FightMode.Closest }, target)) + { + break; + } } base.Trigger(trigger, source, target); diff --git a/Projects/UOContent/Mobiles/BaseCreature.cs b/Projects/UOContent/Mobiles/BaseCreature.cs index d799013a5..551b4ec22 100644 --- a/Projects/UOContent/Mobiles/BaseCreature.cs +++ b/Projects/UOContent/Mobiles/BaseCreature.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using ModernUO.Serialization; using Server.Collections; using Server.ContextMenus; +using Server.Engines.CannedEvil; using Server.Engines.ConPVP; using Server.Engines.MLQuests; using Server.Engines.Quests.Doom; @@ -5780,6 +5781,33 @@ namespace Server.Mobiles } } + /// + /// Brings into the world on this creature's side. A minion of a champion spawn's + /// champion or creatures belongs to that spawn, which deletes it when it clears its creatures. + /// + /// False, and the minion is deleted, if this creature is not in the world. + public bool SpawnMinion(BaseCreature minion, Mobile target = null, Point3D? location = null) + { + var map = Map; + + if (Deleted || map == null || map == Map.Internal) + { + minion.Delete(); + return false; + } + + minion.Team = Team; + ChampionSpawn.FindSpawnOf(this)?.AddMinion(minion); + minion.MoveToWorld(location ?? map.GetRandomNearbyLocation(Location), map); + + if (target != null) + { + minion.Combatant = target; + } + + return true; + } + public virtual void HealStart(Mobile patient) { StopHeal(); diff --git a/Projects/UOContent/Mobiles/Special/Barracoon.cs b/Projects/UOContent/Mobiles/Special/Barracoon.cs index 50e941515..0bc542746 100644 --- a/Projects/UOContent/Mobiles/Special/Barracoon.cs +++ b/Projects/UOContent/Mobiles/Special/Barracoon.cs @@ -131,9 +131,7 @@ public partial class Barracoon : BaseChampion public void SpawnRatmen(Mobile target) { - var map = Map; - - if (map == null) + if (Map == null || Map == Map.Internal) { return; } @@ -162,9 +160,7 @@ public partial class Barracoon : BaseChampion _ => new Ratman() }; - rat.Team = Team; - rat.MoveToWorld(map.GetRandomNearbyLocation(Location), map); - rat.Combatant = target; + SpawnMinion(rat, target); } } diff --git a/Projects/UOContent/Mobiles/Special/LordOaks.cs b/Projects/UOContent/Mobiles/Special/LordOaks.cs index 28577c90b..40a0d2d68 100644 --- a/Projects/UOContent/Mobiles/Special/LordOaks.cs +++ b/Projects/UOContent/Mobiles/Special/LordOaks.cs @@ -133,9 +133,10 @@ public partial class LordOaks : BaseChampion Say(1042153); // Come forth my queen! - Queen = new Silvani { Team = Team }; - Queen.MoveToWorld(Location, Map); + var queen = new Silvani(); + SpawnMinion(queen, location: Location); + Queen = queen; SpawnedQueen = true; }