diff --git a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs
index d186d7999..e014f058d 100644
--- a/Projects/UOContent/Items/Misc/Corpses/Corpse.cs
+++ b/Projects/UOContent/Items/Misc/Corpses/Corpse.cs
@@ -62,10 +62,31 @@ public enum CorpseFlag
///
/// Has this corpse been self looted?
///
- SelfLooted = 0x00000080
+ SelfLooted = 0x00000080,
+
+ ///
+ /// Was the owner a murderer when he died?
+ ///
+ Murderer = 0x00000100,
+
+ ///
+ /// Was the owner a BaseCreature? Snapshot at death so notoriety still resolves after the
+ /// owner Mobile is deleted (BaseCreatures are deleted on death; the reference is null after restart).
+ ///
+ OwnerWasBaseCreature = 0x00000200,
+
+ ///
+ /// Was the owner a summoned creature?
+ ///
+ OwnerWasSummoned = 0x00000400,
+
+ ///
+ /// Was the owner an animated dead creature?
+ ///
+ OwnerWasAnimatedDead = 0x00000800
}
-[SerializationGenerator(15, false)]
+[SerializationGenerator(16, false)]
public partial class Corpse : Container, ICarvable
{
public static readonly TimeSpan MonsterLootRightSacrifice = TimeSpan.FromMinutes(2.0);
@@ -125,21 +146,17 @@ public partial class Corpse : Container, ICarvable
[SerializableField(11, setter: "private")]
private Guild _guild;
- [SerializableField(12)]
- [SerializedCommandProperty(AccessLevel.GameMaster)]
- private bool _murderer;
-
- [SerializableField(13, setter: "private")]
+ [SerializableField(12, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private List- _equipItems;
[CanBeNull]
- [SerializableField(14, setter: "private")]
+ [SerializableField(13, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private VirtualHairInfo _hair;
[CanBeNull]
- [SerializableField(15, setter: "private")]
+ [SerializableField(14, setter: "private")]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private VirtualHairInfo _facialHair;
@@ -171,9 +188,20 @@ public partial class Corpse : Container, ICarvable
_accessLevel = owner.AccessLevel;
_guild = owner.Guild as Guild;
- _murderer = owner.Murderer;
+ SetFlag(CorpseFlag.Murderer, owner.Murderer);
SetFlag(CorpseFlag.Criminal, owner.Criminal);
+ var ownerBaseCreature = owner as BaseCreature;
+
+ // Snapshot owner type & state. BaseCreatures are deleted after death, so the owner mobile
+ // reference becomes null after restart - notoriety must be derivable without it.
+ if (ownerBaseCreature != null)
+ {
+ SetFlag(CorpseFlag.OwnerWasBaseCreature, true);
+ SetFlag(CorpseFlag.OwnerWasSummoned, ownerBaseCreature.Summoned);
+ SetFlag(CorpseFlag.OwnerWasAnimatedDead, ownerBaseCreature.IsAnimatedDead);
+ }
+
if (hair?.ItemId > 0)
{
_hair = new VirtualHairInfo(hair.ItemId, hair.Hue);
@@ -192,8 +220,6 @@ public partial class Corpse : Container, ICarvable
_aggressors = new List(owner.Aggressors.Count + owner.Aggressed.Count);
- var isBaseCreature = owner is BaseCreature;
-
var lastTime = TimeSpan.MaxValue;
for (var i = 0; i < owner.Aggressors.Count; ++i)
@@ -206,7 +232,7 @@ public partial class Corpse : Container, ICarvable
lastTime = Core.Now - info.LastCombatTime;
}
- if (!isBaseCreature && !info.CriminalAggression)
+ if (ownerBaseCreature == null && !info.CriminalAggression)
{
_aggressors.Add(info.Attacker);
}
@@ -222,23 +248,21 @@ public partial class Corpse : Container, ICarvable
lastTime = Core.Now - info.LastCombatTime;
}
- if (!isBaseCreature)
+ if (ownerBaseCreature == null)
{
_aggressors.Add(info.Defender);
}
}
- if (isBaseCreature)
+ if (ownerBaseCreature != null)
{
- var bc = (BaseCreature)owner;
-
- var master = bc.GetMaster();
+ var master = ownerBaseCreature.GetMaster();
if (master != null)
{
_aggressors.Add(master);
}
- var rights = BaseCreature.GetLootingRights(bc.DamageEntries, bc.HitsMax);
+ var rights = BaseCreature.GetLootingRights(ownerBaseCreature.DamageEntries, ownerBaseCreature.HitsMax);
for (var i = 0; i < rights.Count; ++i)
{
var ds = rights[i];
@@ -255,11 +279,40 @@ public partial class Corpse : Container, ICarvable
DevourCorpse();
}
- // Replaced int Kills snapshot with bool Murderer snapshot
- private void MigrateFrom(V14Content content)
+ // Folded Murderer bool field into CorpseFlag.Murderer
+ private void MigrateFrom(V15Content content)
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
+ if (content.Murderer)
+ {
+ _flags |= CorpseFlag.Murderer;
+ }
+ _timeOfDeath = content.TimeOfDeath;
+ _restoreTable = content.RestoreTable;
+ _decayTimer = new InternalTimer(this, content.DecayTimerDelay);
+ _decayTimer.Start();
+ _looters = content.Looters;
+ _killer = content.Killer;
+ _aggressors = content.Aggressors;
+ _owner = content.Owner;
+ _corpseName = content.CorpseName;
+ _accessLevel = content.AccessLevel;
+ _guild = content.Guild;
+ _equipItems = content.EquipItems;
+ _hair = content.Hair;
+ _facialHair = content.FacialHair;
+ }
+
+ // Replaced int Kills snapshot with bool Murderer snapshot
+ private void MigrateFrom(V14Content content)
+ {
+ _restoreEquip = content.RestoreEquip;
+ _flags = content.Flags;
+ if (content.Kills >= 5)
+ {
+ _flags |= CorpseFlag.Murderer;
+ }
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
@@ -271,7 +324,6 @@ public partial class Corpse : Container, ICarvable
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
- _murderer = content.Kills >= 5;
_equipItems = content.EquipItems;
_hair = content.Hair;
_facialHair = content.FacialHair;
@@ -282,6 +334,10 @@ public partial class Corpse : Container, ICarvable
{
_restoreEquip = content.RestoreEquip;
_flags = content.Flags;
+ if (content.Kills >= 5)
+ {
+ _flags |= CorpseFlag.Murderer;
+ }
_timeOfDeath = content.TimeOfDeath;
_restoreTable = content.RestoreTable;
_decayTimer = new InternalTimer(this, content.DecayTimerDelay);
@@ -293,7 +349,6 @@ public partial class Corpse : Container, ICarvable
_corpseName = content.CorpseName;
_accessLevel = content.AccessLevel;
_guild = content.Guild;
- _murderer = content.Kills >= 5;
_equipItems = content.EquipItems;
}
@@ -356,6 +411,22 @@ public partial class Corpse : Container, ICarvable
set => SetFlag(CorpseFlag.Criminal, value);
}
+ [CommandProperty(AccessLevel.GameMaster)]
+ public bool Murderer
+ {
+ get => GetFlag(CorpseFlag.Murderer);
+ set => SetFlag(CorpseFlag.Murderer, value);
+ }
+
+ [CommandProperty(AccessLevel.GameMaster)]
+ public bool OwnerWasBaseCreature => GetFlag(CorpseFlag.OwnerWasBaseCreature);
+
+ [CommandProperty(AccessLevel.GameMaster)]
+ public bool OwnerWasSummoned => GetFlag(CorpseFlag.OwnerWasSummoned);
+
+ [CommandProperty(AccessLevel.GameMaster)]
+ public bool OwnerWasAnimatedDead => GetFlag(CorpseFlag.OwnerWasAnimatedDead);
+
public override bool DisplaysContent => false;
public void Carve(Mobile from, Item item)
@@ -666,7 +737,10 @@ public partial class Corpse : Container, ICarvable
_accessLevel = (AccessLevel)reader.ReadInt();
reader.ReadInt(); // guild reserve
- _murderer = reader.ReadInt() >= 5;
+ if (reader.ReadInt() >= 5)
+ {
+ _flags |= CorpseFlag.Murderer;
+ }
_equipItems = reader.ReadEntityList
- ();
}
diff --git a/Projects/UOContent/Migrations/Server.Items.Corpse.v16.json b/Projects/UOContent/Migrations/Server.Items.Corpse.v16.json
new file mode 100644
index 000000000..3efb84682
--- /dev/null
+++ b/Projects/UOContent/Migrations/Server.Items.Corpse.v16.json
@@ -0,0 +1,123 @@
+{
+ "version": 16,
+ "type": "Server.Items.Corpse",
+ "properties": [
+ {
+ "name": "RestoreEquip",
+ "type": "System.Collections.Generic.List\u003CServer.Item\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Item",
+ "SerializableInterfaceMigrationRule"
+ ]
+ },
+ {
+ "name": "Flags",
+ "type": "Server.Items.CorpseFlag",
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "TimeOfDeath",
+ "type": "System.DateTime",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ "DeltaTime"
+ ]
+ },
+ {
+ "name": "RestoreTable",
+ "type": "System.Collections.Generic.Dictionary\u003CServer.Item, Server.Point3D\u003E",
+ "rule": "DictionaryMigrationRule",
+ "ruleArguments": [
+ "Server.Item",
+ "SerializableInterfaceMigrationRule",
+ "0",
+ "Server.Point3D",
+ "PrimitiveUOTypeMigrationRule",
+ "1",
+ "Point3D"
+ ]
+ },
+ {
+ "name": "DecayTimer",
+ "type": "Server.Timer",
+ "rule": "TimerMigrationRule",
+ "ruleArguments": [
+ "@TimerDrift"
+ ]
+ },
+ {
+ "name": "Looters",
+ "type": "System.Collections.Generic.HashSet\u003CServer.Mobile\u003E",
+ "rule": "HashSetMigrationRule",
+ "ruleArguments": [
+ "Server.Mobile",
+ "SerializableInterfaceMigrationRule"
+ ]
+ },
+ {
+ "name": "Killer",
+ "type": "Server.Mobile",
+ "rule": "SerializableInterfaceMigrationRule"
+ },
+ {
+ "name": "Aggressors",
+ "type": "System.Collections.Generic.List\u003CServer.Mobile\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Mobile",
+ "SerializableInterfaceMigrationRule"
+ ]
+ },
+ {
+ "name": "Owner",
+ "type": "Server.Mobile",
+ "rule": "SerializableInterfaceMigrationRule"
+ },
+ {
+ "name": "CorpseName",
+ "type": "string",
+ "rule": "PrimitiveTypeMigrationRule",
+ "ruleArguments": [
+ ""
+ ]
+ },
+ {
+ "name": "AccessLevel",
+ "type": "Server.AccessLevel",
+ "rule": "EnumMigrationRule"
+ },
+ {
+ "name": "Guild",
+ "type": "Server.Guilds.Guild",
+ "rule": "SerializableInterfaceMigrationRule"
+ },
+ {
+ "name": "EquipItems",
+ "type": "System.Collections.Generic.List\u003CServer.Item\u003E",
+ "rule": "ListMigrationRule",
+ "ruleArguments": [
+ "Server.Item",
+ "SerializableInterfaceMigrationRule"
+ ]
+ },
+ {
+ "name": "Hair",
+ "type": "Server.VirtualHairInfo",
+ "rule": "RawSerializableMigrationRule",
+ "ruleArguments": [
+ "",
+ "@CanBeNull"
+ ]
+ },
+ {
+ "name": "FacialHair",
+ "type": "Server.VirtualHairInfo",
+ "rule": "RawSerializableMigrationRule",
+ "ruleArguments": [
+ "",
+ "@CanBeNull"
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Projects/UOContent/Misc/Notoriety.cs b/Projects/UOContent/Misc/Notoriety.cs
index fb6069f49..df70639c0 100644
--- a/Projects/UOContent/Misc/Notoriety.cs
+++ b/Projects/UOContent/Misc/Notoriety.cs
@@ -297,22 +297,25 @@ namespace Server.Misc
}
}
- if (target.Owner is BaseCreature creature)
+ // BaseCreatures are deleted on death, so target.Owner is null after a server restart.
+ // The OwnerWasBaseCreature flag is the persisted snapshot that survives the live mobile.
+ var creature = target.Owner as BaseCreature;
+
+ if (target.OwnerWasBaseCreature || creature != null)
{
if (srcFaction != null && trgFaction != null && srcFaction != trgFaction && source.Map == Faction.Facet)
{
return Notoriety.Enemy;
}
- if (CheckHouseFlag(source, creature, target.Location, target.Map))
+ if (creature != null && CheckHouseFlag(source, creature, target.Location, target.Map))
{
return Notoriety.CanBeAttacked;
}
var actual = Notoriety.CanBeAttacked;
- if (target.Murderer || body.IsMonster && IsSummoned(creature) ||
- creature.IsAnimatedDead)
+ if (target.Murderer || body.IsMonster && target.OwnerWasSummoned || target.OwnerWasAnimatedDead)
{
actual = Notoriety.Murderer;
}
diff --git a/dev-docs/claude-skills/modernuo-serialization.md b/dev-docs/claude-skills/modernuo-serialization.md
index dae1b8620..c96244656 100644
--- a/dev-docs/claude-skills/modernuo-serialization.md
+++ b/dev-docs/claude-skills/modernuo-serialization.md
@@ -337,9 +337,22 @@ public partial class MagicGem
## Version Migration
Migration schemas are JSON files in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`:
- Format: `TypeName.vN.json`
-- Generated automatically by the serialization generator
+- Read by the serialization generator at compile time to produce `VXContent` types for `MigrateFrom`
- Used for reading old save formats
+### Schema generator must be run after every version bump
+
+The `dotnet build` does **not** emit migration JSON files. After bumping `[SerializationGenerator(N)]` to `N+1`, run the schema generator tool to produce `TypeName.v{N+1}.json`. Commit the new JSON alongside the code change.
+
+```sh
+dotnet tool restore
+dotnet tool run ModernUOSchemaGenerator -- ModernUO.slnx
+```
+
+Verify the new `TypeName.v{N+1}.json` was created in the appropriate `Migrations/` folder. If the JSON is missing, future version bumps that need to migrate from this version will fail to compile (the generator can't build `VXContent` for a version with no schema on disk).
+
+Also available via the build tool: `dotnet run --project Projects/BuildTool -- --action migrate`.
+
External reference: https://github.com/modernuo/SerializationGenerator
## See Also
diff --git a/dev-docs/serialization.md b/dev-docs/serialization.md
index ecd41cdea..151d9d717 100644
--- a/dev-docs/serialization.md
+++ b/dev-docs/serialization.md
@@ -367,6 +367,15 @@ Increment the version number when you:
Located in `Projects/Server/Migrations/` and `Projects/UOContent/Migrations/`.
Format: `Namespace.TypeName.vN.json`
+These JSONs are read by the source generator at compile time to build the `VXContent` types referenced by `MigrateFrom`. They are **not** emitted by `dotnet build` — you must run the schema generator tool after every version bump to produce the new `vN.json`:
+
+```sh
+dotnet tool restore
+dotnet tool run ModernUOSchemaGenerator -- ModernUO.slnx
+```
+
+Verify `Namespace.TypeName.v{N+1}.json` appears in the appropriate `Migrations/` folder, then commit it with the code change. Without the new JSON, the next version bump won't be able to construct `V{N+1}Content` and will fail to compile. Equivalent shortcut via the build tool: `dotnet run --project Projects/BuildTool -- --action migrate`.
+
Example: `Server.Accounting.Account.v6.json`
```json
{