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.
This commit is contained in:
Kamron Batman 2026-05-03 02:15:01 -07:00 committed by GitHub
parent a74c7f9d4e
commit 29e4ecdb1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 251 additions and 29 deletions

View file

@ -62,10 +62,31 @@ public enum CorpseFlag
/// <summary>
/// Has this corpse been self looted?
/// </summary>
SelfLooted = 0x00000080
SelfLooted = 0x00000080,
/// <summary>
/// Was the owner a murderer when he died?
/// </summary>
Murderer = 0x00000100,
/// <summary>
/// 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).
/// </summary>
OwnerWasBaseCreature = 0x00000200,
/// <summary>
/// Was the owner a summoned creature?
/// </summary>
OwnerWasSummoned = 0x00000400,
/// <summary>
/// Was the owner an animated dead creature?
/// </summary>
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<Item> _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<Mobile>(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<Item>();
}

View file

@ -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"
]
}
]
}

View file

@ -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;
}

View file

@ -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

View file

@ -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
{