From 29e4ecdb1b3ceb1fdae0aa2ddacc5e4ac556fb13 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Sun, 3 May 2026 02:15:01 -0700 Subject: [PATCH] fix: Preserve corpse notoriety across server restart (#2426) BaseCreatures are deleted on death (Mobile.OnDeath calls Delete for non-players), so after save/restart the corpse's _owner reference resolves to null. CorpseNotoriety gated its entire creature branch on `target.Owner is BaseCreature`, falling through to player-corpse logic once the reference vanished. That made monster corpses turn red (body.IsMonster -> Murderer) and innocent NPC corpses turn grey (null is not PlayerMobile -> CanBeAttacked) on the next restart. Snapshots the relevant owner state into CorpseFlag at corpse creation: OwnerWasBaseCreature, OwnerWasSummoned, OwnerWasAnimatedDead. Folds the standalone _murderer bool into CorpseFlag.Murderer for consistency with Criminal. CorpseNotoriety now consults the flags so the creature branch stays correct without a live mobile reference. Bumps Corpse serialization to v16 with a MigrateFrom(V15Content) that maps the old Murderer bool onto the new flag. Pre-fix corpses already on disk decay within 7 minutes; their first post-restart color may be wrong, which is acceptable. Also documents that the schema generator must be run after every version bump (`dotnet tool run ModernUOSchemaGenerator -- ModernUO.slnx`) since `dotnet build` does not emit migration JSON files. --- .../UOContent/Items/Misc/Corpses/Corpse.cs | 122 +++++++++++++---- .../Migrations/Server.Items.Corpse.v16.json | 123 ++++++++++++++++++ Projects/UOContent/Misc/Notoriety.cs | 11 +- .../claude-skills/modernuo-serialization.md | 15 ++- dev-docs/serialization.md | 9 ++ 5 files changed, 251 insertions(+), 29 deletions(-) create mode 100644 Projects/UOContent/Migrations/Server.Items.Corpse.v16.json 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 {