refactor: BaseCreature to the SerializationGenerator with SaveFlag elision

Converts BaseCreature's hand-written v22 serialization to codegen v23.
Nearly every field sits behind a [SaveFlag], so a creature matching its
defaults writes only [version int][ulong flags][default AI] - 13 bytes -
instead of ~236, and the writer's work is mostly branch-not-taken. With
500k-1M creatures in a world, this is the dominant slice of mobile save
freeze time and disk.

- Speeds serialize only when they differ from the creature's npc-speeds
  values (GetSpeeds/GetMoveSpeeds on both the flag check and the load
  default), so table edits now reach existing unmodified spawns on
  restart, and former paragons (snapped back to table values) elide
  fully. CurrentSpeed writes only when it differs from PassiveSpeed.
- The delete countdown is a [DeserializeTimer] field (anchored);
  stabled/controlled pets never persist one, and the abandoned-pet
  3-day fallback lives in AfterDeserialization for both load paths.
- SummonEnd stays anchored, written only while summoned.
- Old saves (v0-22) load through the retained legacy
  Deserialize(reader, version), now assigning raw fields; the shared
  post-load fixups (stat timers, AI creation, followers, animate-dead
  registration, unsummon timer) moved to [AfterDeserialization].
- Side-effect setters became generated-field hooks (Team, Controlled,
  Summoned, Loyalty clamp, resistance seeds, CurrentSpeed); properties
  whose semantics the hooks cannot express stay hand-written as
  [SerializableProperty] (ControlMaster/SummonMaster bracket the
  assignment with follower bookkeeping, ControlOrder must run on equal
  re-assignment, Tamable/IsParagon/move speeds have custom getters).
- CreatureDeathEvent/CreatureDeletedEvent moved to a CreatureEvents
  host class: the events generator and the serialization generator each
  emit a [GeneratedCode] partial for the declaring type, and the
  attribute forbids duplicates (CS0579).
- m_ fields renamed to _camelCase.

Tests: new-format round trips (default and fully populated) with exact
byte consumption, back-to-back saves byte-identical, and a
byte-authentic fossilized v22 stream loading through the legacy path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kamron Batman 2026-08-23 10:50:38 -07:00
parent e7f85d404d
commit f4327e6a3a
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
9 changed files with 1465 additions and 600 deletions

View file

@ -0,0 +1,275 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles;
// BaseCreature's move to the SerializationGenerator (v23) is guarded three ways: the new
// SaveFlag format round-trips both a default and a fully-populated creature with exact
// byte consumption, back-to-back saves are byte-identical (freeze-time stability), and a
// byte-authentic pre-codegen v22 stream (written by a fossilized replica of the old
// Serialize) loads through the legacy path with the table-speed migration applied.
[Collection("Sequential UOContent Tests")]
public class BaseCreatureSerializationTests : IDisposable
{
private readonly List<Mobile> _created = new();
public void Dispose()
{
for (var i = 0; i < _created.Count; i++)
{
_created[i].Delete();
}
}
private class CreatureStub : BaseCreature
{
public CreatureStub() : base(AIType.AI_Melee) => Body = 0xC9;
public CreatureStub(Serial serial) : base(serial) => Body = 0xC9;
// Stands in for the npc-speeds table (unconfigured in the test fixture).
public override void GetSpeeds(out double activeSpeed, out double passiveSpeed)
{
activeSpeed = 0.3;
passiveSpeed = 0.6;
}
public override void GetMoveSpeeds(out double activeMoveSpeed, out double passiveMoveSpeed)
{
activeMoveSpeed = 0.6;
passiveMoveSpeed = 1.2;
}
}
private CreatureStub NewCreature()
{
var bc = new CreatureStub();
_created.Add(bc);
return bc;
}
private static byte[] Snapshot(Mobile m)
{
var writer = new BufferWriter(true);
m.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
return buffer;
}
private CreatureStub Load(byte[] buffer)
{
var copy = new CreatureStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position); // exact consumption
return copy;
}
[Fact]
public void DefaultCreature_RoundTrips_AndElidesEverything()
{
var bc = NewCreature();
var buffer = Snapshot(bc);
var copy = Load(buffer);
Assert.Equal(AIType.AI_Melee, copy.AI);
Assert.Equal(BaseCreature.DefaultRangePerception, copy.RangePerception);
Assert.Equal(0.3, copy.ActiveSpeed);
Assert.Equal(0.6, copy.PassiveSpeed);
Assert.Equal(0.6, copy.CurrentSpeed);
Assert.Equal(0.6, copy.ActiveMoveSpeed); // pulled from the table, not the wire
Assert.Equal(1.2, copy.PassiveMoveSpeed);
Assert.Equal(100, copy.PhysicalDamage);
Assert.Equal(BaseCreature.MaxLoyalty, copy.Loyalty);
Assert.Equal(1, copy.ControlSlots);
Assert.NotNull(copy.Owners);
Assert.Empty(copy.Owners);
}
[Fact]
public void BackToBackSaves_AreByteIdentical()
{
var bc = NewCreature();
bc.SetDamage(5, 10);
bc.PhysicalResistanceSeed = 25;
Assert.Equal(Snapshot(bc), Snapshot(bc));
}
[Fact]
public void PopulatedCreature_RoundTrips()
{
var bc = NewCreature();
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
World.AddEntity(master); // ReadEntity resolves the reference through the world table
_created.Add(master);
bc.Tamable = true;
bc.MinTameSkill = 47.1;
bc.SetControlMaster(master);
bc.Owners.Add(master);
bc.ControlOrder = OrderType.Guard;
bc.SetDamage(11, 17);
bc.SetSpeed(0.2, 0.4); // hand-tuned: no longer matches the stub table
bc.SetMoveSpeed(0.25, 0.5);
bc.PhysicalResistanceSeed = 40;
bc.EnergyResistSeed = 15;
bc.FireDamage = 25;
bc.PhysicalDamage = 75;
bc.HitsMaxSeed = 250;
bc.Loyalty = 55;
bc.Home = new Point3D(1000, 1100, 5);
bc.RangeHome = 4;
bc.Team = 3;
bc.IsBonded = true;
bc.BondingBegin = Core.Now;
bc.RemoveIfUntamed = true;
bc.RemoveStep = 2;
bc.CorpseNameOverride = "a test corpse";
var copy = Load(Snapshot(bc));
Assert.True(copy.Controlled);
Assert.Equal(master, copy.ControlMaster);
Assert.Equal(OrderType.Guard, copy.ControlOrder);
Assert.True(copy.Tamable);
Assert.Equal(47.1, copy.MinTameSkill);
Assert.Equal(11, copy.DamageMin);
Assert.Equal(17, copy.DamageMax);
Assert.Equal(0.2, copy.ActiveSpeed);
Assert.Equal(0.4, copy.PassiveSpeed);
Assert.Equal(0.25, copy.ActiveMoveSpeed);
Assert.Equal(0.5, copy.PassiveMoveSpeed);
Assert.Equal(40, copy.PhysicalResistanceSeed);
Assert.Equal(15, copy.EnergyResistSeed);
Assert.Equal(25, copy.FireDamage);
Assert.Equal(75, copy.PhysicalDamage);
Assert.Equal(250, copy.HitsMaxSeed);
Assert.Equal(55, copy.Loyalty);
Assert.Equal(new Point3D(1000, 1100, 5), copy.Home);
Assert.Equal(4, copy.RangeHome);
Assert.Equal(3, copy.Team);
Assert.True(copy.IsBonded);
Assert.Equal(bc.BondingBegin, copy.BondingBegin);
Assert.True(copy.RemoveIfUntamed);
Assert.Equal(2, copy.RemoveStep);
Assert.Equal("a test corpse", copy.CorpseNameOverride);
Assert.Equal(master, copy.LastOwner);
}
private sealed class MobileStub : Mobile
{
public MobileStub() => Body = 0xC9;
}
// Byte-authentic replica of the pre-codegen v22 tail — fossilized so the legacy
// upgrade path stays covered without an old save binary. The full stream is a plain
// Mobile section (identical layout for every Mobile subclass) followed by this tail.
private static void WriteLegacyV22Tail(IGenericWriter writer)
{
writer.Write(22); // version
writer.Write((int)AIType.AI_Melee); // current AI
writer.Write((int)AIType.AI_Melee); // default AI
writer.Write(10); // RangePerception
writer.Write(1); // RangeFight
writer.Write(0); // Team
writer.Write(0.3); // active (matches the stub table)
writer.Write(0.6); // passive
writer.Write(0.6); // current
writer.Write(2000); // Home X
writer.Write(2100); // Home Y
writer.Write(7); // Home Z
writer.Write(6); // RangeHome
writer.Write((int)FightMode.Closest);
writer.Write(false); // controlled
writer.Write((Mobile)null); // control master
writer.Write((Mobile)null); // control target
writer.Write(Point3D.Zero); // control dest
writer.Write((int)OrderType.None);
writer.Write(0.0); // min tame skill
writer.Write(true); // tamable
writer.Write(false); // summoned
writer.Write(2); // control slots
writer.Write(73); // loyalty
writer.Write((Item)null); // waypoint
writer.Write((Mobile)null); // summon master
writer.Write(180); // hits seed
writer.Write(-1); // stam seed
writer.Write(-1); // mana seed
writer.Write(7); // damage min
writer.Write(14); // damage max
writer.Write(30); // phys resist
writer.Write(100); // phys damage
writer.Write(10); // fire resist
writer.Write(0); // fire damage
writer.Write(0); // cold resist
writer.Write(0); // cold damage
writer.Write(0); // poison resist
writer.Write(0); // poison damage
writer.Write(0); // energy resist
writer.Write(0); // energy damage
writer.Write(new List<Mobile>()); // owners
writer.Write(false); // dead pet
writer.Write(false); // bonded
writer.Write(DateTime.MinValue); // bonding begin
writer.Write(DateTime.MinValue); // abandon time
writer.Write(true); // has generated loot
writer.Write(false); // paragon
writer.Write(false); // has friends
writer.Write(false); // remove if untamed
writer.Write(0); // remove step
writer.Write(TimeSpan.Zero); // delete time left
writer.Write((string)null); // corpse name override
writer.Write((Map)null); // home map
writer.Write(0.0); // active move speed (v22)
writer.Write(0.0); // passive move speed (v22)
}
[Fact]
public void LegacyV22Stream_LoadsThroughLegacyPath()
{
// Every serialized BaseCreature starts with the Mobile base section; a plain
// Mobile donor produces a byte-authentic one.
var donor = new MobileStub();
donor.DefaultMobileInit();
_created.Add(donor);
var writer = new BufferWriter(true);
donor.Serialize(writer);
WriteLegacyV22Tail(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new CreatureStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(10, copy.RangePerception);
Assert.Equal(new Point3D(2000, 2100, 7), copy.Home);
Assert.Equal(6, copy.RangeHome);
Assert.True(copy.Tamable);
Assert.Equal(2, copy.ControlSlots);
Assert.Equal(73, copy.Loyalty);
Assert.Equal(180, copy.HitsMaxSeed);
Assert.Equal(7, copy.DamageMin);
Assert.Equal(14, copy.DamageMax);
Assert.Equal(30, copy.PhysicalResistanceSeed);
Assert.Equal(10, copy.FireResistSeed);
Assert.Equal(0.3, copy.ActiveSpeed);
// v22 wrote explicit zeros for the move overrides ("inherit"), so the resolved
// pace falls back to the think clock through the resolving getters.
Assert.Equal(0.3, copy.ActiveMoveSpeed);
}
}

View file

@ -121,7 +121,7 @@ public class BloodOathSpellTests
BloodOathSpell.RegisterOath(caster, target, TimeSpan.FromMinutes(5));
BaseCreature.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
CreatureEvents.CreatureDeletedEvent(target); // central handler breaks the oath from the target side
Assert.Null(BloodOathSpell.GetBloodOath(target));
Assert.False(BloodOathSpell.RemoveCurse(caster));

View file

@ -0,0 +1,471 @@
{
"version": 23,
"type": "Server.Mobiles.BaseCreature",
"properties": [
{
"name": "DefaultAI",
"type": "Server.Mobiles.AIType",
"rule": "EnumMigrationRule"
},
{
"name": "CurrentAI",
"type": "Server.Mobiles.AIType",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "RangePerception",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "RangeFight",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "RangeHome",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "Team",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "FightMode",
"type": "Server.Mobiles.FightMode",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "ActiveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "PassiveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "CurrentSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "ActiveMoveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "PassiveMoveSpeed",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Home",
"type": "Server.Point3D",
"usesSaveFlag": true,
"rule": "PrimitiveUOTypeMigrationRule",
"ruleArguments": [
"Point3D"
]
},
{
"name": "HomeMap",
"type": "Server.Map",
"usesSaveFlag": true,
"rule": "PrimitiveUOTypeMigrationRule",
"ruleArguments": [
"Map"
]
},
{
"name": "Controlled",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "ControlMaster",
"type": "Server.Mobile",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "ControlTarget",
"type": "Server.Mobile",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "ControlDest",
"type": "Server.Point3D",
"usesSaveFlag": true,
"rule": "PrimitiveUOTypeMigrationRule",
"ruleArguments": [
"Point3D"
]
},
{
"name": "ControlOrder",
"type": "Server.Mobiles.OrderType",
"usesSaveFlag": true,
"rule": "EnumMigrationRule"
},
{
"name": "MinTameSkill",
"type": "double",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Tamable",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Summoned",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "SummonEnd",
"type": "System.DateTime",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"AnchoredTime"
]
},
{
"name": "SummonMaster",
"type": "Server.Mobile",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "ControlSlots",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "Loyalty",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "CurrentWayPoint",
"type": "Server.Items.WayPoint",
"usesSaveFlag": true,
"rule": "SerializableInterfaceMigrationRule"
},
{
"name": "HitsMaxSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "StamMaxSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "ManaMaxSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "DamageMin",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "DamageMax",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PhysicalResistanceSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "FireResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "ColdResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PoisonResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "EnergyResistSeed",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PhysicalDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "FireDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "ColdDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PoisonDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "EnergyDamage",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "Owners",
"type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
"usesSaveFlag": true,
"rule": "ListMigrationRule",
"ruleArguments": [
"@Tidy",
"Server.Mobile",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "IsDeadPet",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "IsBonded",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "BondingBegin",
"type": "System.DateTime",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "OwnerAbandonTime",
"type": "System.DateTime",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "HasGeneratedLoot",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "IsParagon",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "Friends",
"type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
"usesSaveFlag": true,
"rule": "ListMigrationRule",
"ruleArguments": [
"@Tidy",
"Server.Mobile",
"SerializableInterfaceMigrationRule"
]
},
{
"name": "RemoveIfUntamed",
"type": "bool",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
},
{
"name": "RemoveStep",
"type": "int",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
"EncodedInt"
]
},
{
"name": "PendingDeleteTimer",
"type": "Server.Timer",
"usesSaveFlag": true,
"rule": "TimerMigrationRule",
"ruleArguments": [
"@AnchoredTimer"
]
},
{
"name": "CorpseNameOverride",
"type": "string",
"usesSaveFlag": true,
"rule": "PrimitiveTypeMigrationRule",
"ruleArguments": [
""
]
}
]
}

View file

@ -99,8 +99,8 @@ public abstract class MonsterAbility
{
}
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
[OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
public static void InvalidateNextAbilityTriggers(BaseCreature source)
{
var abilities = source.GetMonsterAbilities();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
using ModernUO.CodeGeneratedEvents;
namespace Server.Mobiles;
// Hosts BaseCreature's generated events. They cannot live on BaseCreature itself: the
// events generator and the serialization generator each emit a [GeneratedCode] partial for
// the declaring type, and the attribute does not allow duplicates (CS0579).
public static partial class CreatureEvents
{
[GeneratedEvent(nameof(CreatureDeathEvent))]
public static partial void CreatureDeathEvent(BaseCreature bc);
[GeneratedEvent(nameof(CreatureDeletedEvent))]
public static partial void CreatureDeletedEvent(BaseCreature bc);
}

View file

@ -154,7 +154,7 @@ namespace Server.Mobiles
public static bool UnderEffect(Mobile m) => m_Table.ContainsKey(m);
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
[OnEvent(nameof(CreatureDeathEvent))]
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
public static void StopEffect(Mobile m, bool message = false)
{
if (m_Table.Remove(m, out var timer))

View file

@ -151,8 +151,8 @@ public class BloodOathSpell : NecromancerSpell, ITargetingSpell<Mobile>
// shared timer from either the caster or the target key, so a single call per mobile is enough.
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeletedEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
[OnEvent(nameof(BaseCreature.CreatureDeletedEvent))]
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
[OnEvent(nameof(CreatureEvents.CreatureDeletedEvent))]
public static void OnCurseEnds(Mobile m) => RemoveCurse(m);
private class ExpireTimer : Timer

View file

@ -79,7 +79,7 @@ namespace Server.Spells.Spellweaving
Caster.Target = new SpellTarget<Mobile>(this, TargetFlags.Beneficial);
}
[OnEvent(nameof(BaseCreature.CreatureDeathEvent))]
[OnEvent(nameof(CreatureEvents.CreatureDeathEvent))]
[OnEvent(nameof(PlayerMobile.PlayerDeathEvent))]
public static void OnDeathEvent(Mobile m)
{