fix: champion minions belong to their spawn (#2717)
Stacked on #2716. Retarget to `main` after it merges. ## Problem Mobiles a champion summons mid-fight aren't tracked by anything: | Summoner | Minions | |---|---| | LordOaks | Silvani queen | | `SummonPixiesCounter` (LordOaks, Silvani, Twaulo) | 3-6 pixies per trigger | | Barracoon | 3-6 ratmen per trigger | `ChampionSpawn.DeleteCreatures` (called on expiry and when the spawn is deleted) clears only `Creatures` and the champion. These minions survive every reset and stay in the save. I surveyed every content creature that creates other mobiles; the others already clean up after themselves: Meraktus deletes its followers, Harrower kills its tentacles, PlagueSpawn and the Enraged animals delete themselves when their owner is gone, and BogThing eats its boglings. ## Changes - **`BaseCreature.SpawnMinion(minion, target, location)`:** sets the team, places the minion and sets its combatant. Ownership: if the summoner is a champion spawn's champion, one of its creatures, or one of its minions (found through the spawn region), the minion joins that spawn. It refuses, and deletes the minion, if the summoner isn't on a real map. `SummonPixiesCounter` read `source.Map` even though `MonsterAbility.Trigger` notes it can fire after the creature has left the world. - **`ChampionSpawn.Minions`:** a new serialized list (v12, `MigrateFrom(V11Content)`) kept apart from `Creatures`: - killing a minion never counts toward a level, so a leftover summoner can't feed a restarted spawn; - `DeleteCreatures` deletes minions along with the creatures, which matches when the spawn is deactivated (`ExpireCreatures`, 10 minutes after `Stop`) or deleted. - **Callers:** LordOaks, `SummonPixiesCounter` and Barracoon spawn through `SpawnMinion`. A summoner that isn't part of a spawn (a GM `[add]`) still makes standalone minions, as today. ## Testing - `ChampionMinionTests`: - the champion's queen is listed in `Minions` and deleted by `DeleteCreatures`; - a minion's minion also belongs to the spawn; - a Lord Oaks in the region that isn't the spawn's champion keeps a standalone queen. - UOContent.Tests: 1237 passed, 2 skipped. The Application build is clean.
This commit is contained in:
parent
4e57ec2373
commit
69ff5f3940
7 changed files with 402 additions and 12 deletions
|
|
@ -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<IEntity> _created = [];
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var m in World.Mobiles.Values.OfType<Silvani>().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<Silvani>().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<Silvani>().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<Silvani>().Single();
|
||||
|
||||
spawn.DeleteCreatures();
|
||||
|
||||
Assert.False(queen.Deleted);
|
||||
Assert.Null(ChampionSpawn.FindSpawnOf(oaks));
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Mobile> _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<Mobile>();
|
||||
_minions = [];
|
||||
_redSkulls = new List<Item>();
|
||||
_whiteSkulls = new List<Item>();
|
||||
|
||||
|
|
@ -294,6 +330,21 @@ public partial class ChampionSpawn : Item
|
|||
|
||||
public bool IsChampionSpawn(Mobile m) => _creatures.Contains(m);
|
||||
|
||||
/// <summary>
|
||||
/// The spawn <paramref name="creature" /> fights for: as its champion, one of its creatures, or a minion of either.
|
||||
/// </summary>
|
||||
public static ChampionSpawn FindSpawnOf(BaseCreature creature)
|
||||
{
|
||||
var spawn = Region.Find(creature.Location, creature.Map).GetRegion<ChampionSpawnRegion>()?.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<Mobile>();
|
||||
_minions = [];
|
||||
_redSkulls = reader.ReadEntityList<Item>();
|
||||
_whiteSkulls = reader.ReadEntityList<Item>();
|
||||
_platform = reader.ReadEntity<ChampionPlatform>();
|
||||
|
|
|
|||
199
Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json
generated
Normal file
199
Projects/UOContent/Migrations/Server.Engines.CannedEvil.ChampionSpawn.v12.json
generated
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings <paramref name="minion" /> 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.
|
||||
/// </summary>
|
||||
/// <returns>False, and the minion is deleted, if this creature is not in the world.</returns>
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue