Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
Kamron Batman
a082202e98
refactor: serialize one master reference; fold the SummonMaster lockstep
ControlMaster and SummonMaster, when both set, are always the same
mobile: BaseCreature.Summon assigns both to the caster, and every pet
management flow (transfer, stable, claim, ball of summoning, GM obey,
login overflow) followed SetControlMaster with an identical SummonMaster
assignment. They differ only in presence - uncontrolled summons carry
only a summon master, pets only a control master.

So one _master reference serializes (refreshed at save, fanned back out
through the Controlled/Summoned flags in AfterDeserialization; legacy
loads feed the same path), and SetControlMaster now keeps SummonMaster
in lockstep itself, deleting the six hand-rolled copies of that
boilerplate.

Also: a creature constructed without speeds (missing npc-speeds.json)
now logs debug and defaults to Medium (0.25/0.5) instead of throwing -
this is the place a sane default belongs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 20:07:35 -07:00
Kamron Batman
6d7eb24cc1
test: drop the fossilized v22 legacy-stream test
It served its purpose validating the migration during development; the
legacy path is one-time upgrade code and the replica writer was most of
the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 19:18:07 -07:00
Kamron Batman
cdcf82cfd8
refactor: None means custom; the type list resolves only at construction
The None -> type list -> Medium chain was construction-time defaulting
(so creatures without a bucket or a SetSpeed call never spawn at 0/0),
not a live semantic. The constructor now resolves it once into
_speedClass itself, so at runtime a concrete bucket means table-backed
and None means the creature's own speeds are authoritative - which is
what SpeedLevel.Custom was; it is removed. Runtime entry resolution
collapses to a single level lookup, and the SpeedClass byte still
elides by comparing against the (cached) resolved type default.

Legacy loads guess the type default and demote to None when the loaded
speeds do not conform, so pre-codegen customized creatures (SetSpeed
vendors) come out honestly labeled. A constructor guard keeps a missing
speed table loud instead of spawning 0-delay creatures that spin their
AI timers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:13:46 -07:00
Kamron Batman
4238980c6d
feat: treat the four speeds as one block against the bucket
Either all four values (active/passive think + move) conform to the
creature's speed entry - elided as a set - or the creature is fully
custom and all four serialize. Partial conformance cannot exist on the
wire, so no value is ever left silently tracking the table beside a
hand-tuned sibling.

"Fully custom" is a real state: SpeedLevel.Custom (None already means
"resolve by type list" for the type-listed species, so it cannot double
as the custom marker). Tuning any speed flips the bucket to Custom (the
label never lies), Custom resolves no entry and short-circuits the
conformance check, a custom creature is its own GetSpeeds reference
(paragon snap becomes a natural no-op), and assigning a real bucket
un-customs it via ApplySpeedClass. A re-entrancy guard keeps the flip
from misreading ApplySpeedClass's half-assigned block, and constructors
seed raw fields so DefaultSpeedClass types do not flip at birth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 13:05:51 -07:00
Kamron Batman
695efc7d6e
feat: stateful SpeedClass; the type constant moves to DefaultSpeedClass
A virtual SpeedClass could be overridden dynamically (boss state change)
and silently diverge from the cached speed entry. SpeedClass is now
non-virtual instance state: assigning it invalidates the cached entry,
applies the new bucket's think and move speeds (preserving the
active/passive mode), and serializes only when it differs from the
type's DefaultSpeedClass - so a runtime bucket change survives a save
while its (bucket-matching) speeds still elide. Works from [props too.

Overrides become: DefaultSpeedClass for a type's constant bucket,
SpeedClass assignment for state changes, GetSpeeds/GetMoveSpeeds to
bypass the table entirely - none of which can leave the cache stale.

SpeedClass deserializes before the speed fields (index 7; later indexes
shift by one - v23 was never released, schema regenerated).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:50:17 -07:00
Kamron Batman
f2f8313b42
perf: cache the resolved speed entry per creature
Serialization consults the speed table four times per mob per save (the
Should* flag checks) and again through the Default* methods on elided
loads - each a SpeedClass/type dictionary walk. The resolved
SpeedClassEntry is now cached on the creature (one reference; the table
is immutable after Configure), so those become a null-check and field
reads. GetSpeeds/GetMoveSpeeds stay the virtual override point, so
stubs and forks that override them still steer elision; only their
default implementations read the cache. NPCSpeeds' per-call lookups
collapse into FindEntry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:40:03 -07:00
Kamron Batman
f46780d554
refactor: DamageMin/DamageMax need not be virtual either
No overrides exist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:35:44 -07:00
Kamron Batman
93b9238f6c
refactor: sweep BaseCreature comments to concise constraints
Removes narrative and developer commentary (historical essays, changelog
notes, say-what-the-code-does lines, flavor) and condenses the keepers:
constraint statements, wire-format markers, era-behavior notes, and the
weighted-random distribution table. TODOs use the terse house style.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:35:02 -07:00
Kamron Batman
232c5ed868
Removes virtual 2026-08-23 12:29:06 -07:00
Kamron Batman
963b9b3b85
refactor: collapse the move-speed properties into serialized fields
ActiveMoveSpeed/PassiveMoveSpeed become plain [SerializableField]s (not
virtual): the properties now read the raw override (0 = inheriting) and
CurrentMoveSpeed carries the inherit resolution - it was the only
production reader of the resolving getters. The <=0 coercion moves to an
allowFieldChange hook. Wire format unchanged (schema diff is empty).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 12:27:56 -07:00
Kamron Batman
f4327e6a3a
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>
2026-08-23 10:50:38 -07:00
17 changed files with 1682 additions and 764 deletions

View file

@ -57,8 +57,9 @@ public class MoveSpeedTests : IDisposable
{
var bc = NewCreature();
Assert.Equal(0.3, bc.ActiveMoveSpeed);
Assert.Equal(0.6, bc.PassiveMoveSpeed);
// 0 = no override; the resolved pace comes from CurrentMoveSpeed.
Assert.Equal(0, bc.ActiveMoveSpeed);
Assert.Equal(0, bc.PassiveMoveSpeed);
Assert.Equal(bc.CurrentSpeed, bc.CurrentMoveSpeed);
}
@ -96,8 +97,8 @@ public class MoveSpeedTests : IDisposable
bc.SetSpeed(0.2, 0.4);
Assert.Equal(0.2, bc.ActiveMoveSpeed);
Assert.Equal(0.4, bc.PassiveMoveSpeed);
Assert.Equal(0, bc.ActiveMoveSpeed);
Assert.Equal(0, bc.PassiveMoveSpeed);
}
[Fact]
@ -108,8 +109,10 @@ public class MoveSpeedTests : IDisposable
bc.ActiveMoveSpeed = 0;
Assert.Equal(0.3, bc.ActiveMoveSpeed); // inheriting again
Assert.Equal(0, bc.ActiveMoveSpeed); // inheriting again
Assert.Equal(0.9, bc.PassiveMoveSpeed); // other override untouched
bc.SetCurrentSpeedToActive();
Assert.Equal(0.3, bc.CurrentMoveSpeed); // resolves to the think clock
}
[Fact]
@ -121,7 +124,7 @@ public class MoveSpeedTests : IDisposable
bc.ScaleMoveSpeed(1.0 / 1.2);
Assert.Equal(0.5, bc.ActiveMoveSpeed);
Assert.Equal(bc.PassiveSpeed, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
Assert.Equal(0, bc.PassiveMoveSpeed); // still inheriting, not 0 * scalar
}
[Fact]
@ -185,8 +188,8 @@ public class MoveSpeedTests : IDisposable
bc.MigrateMoveSpeeds();
Assert.Equal(0.35, bc.ActiveMoveSpeed);
Assert.Equal(0.6, bc.PassiveMoveSpeed);
Assert.Equal(0, bc.ActiveMoveSpeed); // still inheriting the (tuned) think clock
Assert.Equal(0, bc.PassiveMoveSpeed);
}
[Theory]
@ -213,7 +216,7 @@ public class MoveSpeedTests : IDisposable
// The v22 tail is the last block; exact consumption catches any offset mistake.
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(overridden ? 0.45 : 0.3, copy.ActiveMoveSpeed);
Assert.Equal(overridden ? 0.9 : 0.6, copy.PassiveMoveSpeed);
Assert.Equal(overridden ? 0.45 : 0, copy.ActiveMoveSpeed);
Assert.Equal(overridden ? 0.9 : 0, copy.PassiveMoveSpeed);
}
}

View file

@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using Server;
using Server.Mobiles;
using Xunit;
namespace UOContent.Tests.Mobiles;
// BaseCreature's SaveFlag format round-trips both a default and a fully-populated
// creature with exact byte consumption, and back-to-back saves are byte-identical
// (freeze-time stability).
[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); // class None (no table in tests): restored from 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);
}
[Fact]
public void UncontrolledSummon_KeepsItsSummonMaster()
{
var bc = NewCreature();
var master = new PlayerMobile(World.NewMobile);
master.DefaultMobileInit();
World.AddEntity(master);
_created.Add(master);
// Energy vortex-style: summoned with a master, never controlled.
bc.Summoned = true;
bc.SummonMaster = master;
var copy = Load(Snapshot(bc));
Assert.True(copy.Summoned);
Assert.False(copy.Controlled);
Assert.Equal(master, copy.SummonMaster);
Assert.Null(copy.ControlMaster);
}
private sealed class BucketStub : BaseCreature
{
public BucketStub() : base(AIType.AI_Melee) => Body = 0xC9;
public BucketStub(Serial serial) : base(serial) => Body = 0xC9;
public override SpeedLevel DefaultSpeedClass => SpeedLevel.Fast;
}
[Fact]
public void SpeedClass_Assignment_AppliesBucket_AndRoundTrips()
{
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4,
ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet<Type>()
});
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.VeryFast, ActiveSpeed = 0.125, PassiveSpeed = 0.3,
ActiveMoveSpeed = 0.125, PassiveMoveSpeed = 0.6, Types = new HashSet<Type>()
});
var bc = new BucketStub();
_created.Add(bc);
Assert.Equal(0.2, bc.ActiveSpeed); // seeded from the default bucket
Assert.Equal(0.3, bc.ActiveMoveSpeed);
bc.SpeedClass = SpeedLevel.VeryFast; // boss state change
Assert.Equal(SpeedLevel.VeryFast, bc.SpeedClass); // conforming assignment holds
Assert.Equal(0.125, bc.ActiveSpeed);
Assert.Equal(0.3, bc.PassiveSpeed);
Assert.Equal(0.125, bc.ActiveMoveSpeed);
Assert.Equal(0.6, bc.PassiveMoveSpeed);
Assert.Equal(0.3, bc.CurrentSpeed); // stayed in the passive mode
// The changed bucket persists; the (bucket-matching) speeds elide but restore
// through the new bucket - the consistency the stateful SpeedClass guarantees.
var writer = new BufferWriter(true);
bc.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new BucketStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(SpeedLevel.VeryFast, copy.SpeedClass);
Assert.Equal(0.125, copy.ActiveSpeed);
Assert.Equal(0.3, copy.PassiveSpeed);
Assert.Equal(0.125, copy.ActiveMoveSpeed);
Assert.Equal(0.6, copy.PassiveMoveSpeed);
}
[Fact]
public void PartialSpeedTuning_MakesTheCreatureFullyCustom()
{
NPCSpeeds.RegisterSpeed(new NPCSpeeds.SpeedClassEntry
{
Level = SpeedLevel.Fast, ActiveSpeed = 0.2, PassiveSpeed = 0.4,
ActiveMoveSpeed = 0.3, PassiveMoveSpeed = 0.9, Types = new HashSet<Type>()
});
var bc = new BucketStub();
_created.Add(bc);
bc.ActiveSpeed = 0.25; // one tuned value customizes the whole block
Assert.Equal(SpeedLevel.None, bc.SpeedClass); // the bucket label never lies
var writer = new BufferWriter(true);
bc.Serialize(writer);
var buffer = new byte[writer.Position];
writer.Buffer.AsSpan(0, (int)writer.Position).CopyTo(buffer);
var copy = new BucketStub(World.NewMobile);
_created.Add(copy);
var reader = new BufferReader(buffer);
copy.Deserialize(reader);
// All four persisted raw - no value is left silently tracking the table.
Assert.Equal(buffer.Length, reader.Position);
Assert.Equal(SpeedLevel.None, copy.SpeedClass);
Assert.Equal(0.25, copy.ActiveSpeed);
Assert.Equal(0.4, copy.PassiveSpeed);
Assert.Equal(0.3, copy.ActiveMoveSpeed);
Assert.Equal(0.9, copy.PassiveMoveSpeed);
}
}

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

@ -227,12 +227,6 @@ public partial class BallOfSummoning : Item, TranslocationItem
if (pet.IsStabled)
{
pet.SetControlMaster(from);
if (pet.Summoned)
{
pet.SummonMaster = from;
}
pet.ControlTarget = from;
pet.ControlOrder = OrderType.Follow;

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": "SpeedClass",
"type": "Server.Mobiles.SpeedLevel",
"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": "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": "Master",
"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

@ -447,11 +447,6 @@ public abstract partial class BaseAI
if (Mobile.FindMyName(e.Speech, true) && e.Speech.InsensitiveContains("obey"))
{
Mobile.SetControlMaster(e.Mobile);
if (Mobile.Summoned)
{
Mobile.SummonMaster = e.Mobile;
}
}
}
}

View file

@ -156,11 +156,6 @@ internal sealed partial class TransferItem : Item
private void TransferPetOwnership(Mobile from, Mobile to)
{
if (_creature.Summoned)
{
_creature.SummonMaster = to;
}
_creature.ControlTarget = to;
_creature.ControlOrder = OrderType.Follow;
_creature.BondingBegin = DateTime.MinValue;

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

@ -8,7 +8,7 @@ namespace Server.Mobiles;
public enum SpeedLevel
{
None,
None, // no bucket: the creature's own speeds are authoritative (custom)
VerySlow,
Slow,
Medium,
@ -26,33 +26,28 @@ public static class NPCSpeeds
public static int MinIdleSeconds { get; private set; }
public static int MaxIdleSeconds { get; private set; }
public static void GetSpeeds(BaseCreature bc, out double activeSpeed, out double passiveSpeed)
// Construction-time resolution of a type's bucket: an explicit DefaultSpeedClass,
// else the table's type list, else Medium so unconfigured creatures never construct
// at 0/0. None only when the table itself is unloaded (test fixtures).
public static SpeedLevel ResolveDefaultLevel(BaseCreature bc)
{
if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
!_speedsByType.TryGetValue(bc.GetType(), out sp))
if (bc.DefaultSpeedClass != SpeedLevel.None)
{
sp = _speedsByLevel[SpeedLevel.Medium];
return bc.DefaultSpeedClass;
}
activeSpeed = sp.ActiveSpeed;
passiveSpeed = sp.PassiveSpeed;
if (_speedsByType.TryGetValue(bc.GetType(), out var sp))
{
return sp.Level;
}
// Move speeds are optional (0 = inherit), so this tolerates a missing entry or table.
public static void GetMoveSpeeds(BaseCreature bc, out double activeMoveSpeed, out double passiveMoveSpeed)
{
if ((bc.SpeedClass == SpeedLevel.None || !_speedsByLevel.TryGetValue(bc.SpeedClass, out var sp)) &&
!_speedsByType.TryGetValue(bc.GetType(), out sp) &&
!_speedsByLevel.TryGetValue(SpeedLevel.Medium, out sp))
{
activeMoveSpeed = 0;
passiveMoveSpeed = 0;
return;
return _speedsByLevel.ContainsKey(SpeedLevel.Medium) ? SpeedLevel.Medium : SpeedLevel.None;
}
activeMoveSpeed = sp.ActiveMoveSpeed;
passiveMoveSpeed = sp.PassiveMoveSpeed;
}
// Null for None (custom) or an unloaded table. Creatures cache the result — the
// table is immutable after Configure.
public static SpeedClassEntry FindEntry(SpeedLevel level) =>
level == SpeedLevel.None ? null : _speedsByLevel.GetValueOrDefault(level);
public static void RegisterSpeed(SpeedClassEntry entry)
{

View file

@ -3553,7 +3553,6 @@ namespace Server.Mobiles
pet.Internalize();
pet.SetControlMaster(null);
pet.SummonMaster = null;
pet.IsStabled = true;
pet.StabledBy = this;
@ -3601,12 +3600,6 @@ namespace Server.Mobiles
if (Followers + pet.ControlSlots <= FollowersMax)
{
pet.SetControlMaster(this);
if (pet.Summoned)
{
pet.SummonMaster = this;
}
pet.ControlTarget = this;
pet.ControlOrder = OrderType.Follow;

View file

@ -255,7 +255,6 @@ namespace Server.Mobiles
pet.Internalize();
pet.SetControlMaster(null);
pet.SummonMaster = null;
pet.IsStabled = true;
pet.StabledBy = from;
@ -356,12 +355,6 @@ namespace Server.Mobiles
private void DoClaim(Mobile from, BaseCreature pet)
{
pet.SetControlMaster(from);
if (pet.Summoned)
{
pet.SummonMaster = from;
}
pet.ControlTarget = from;
pet.ControlOrder = OrderType.Follow;

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

View file

@ -262,8 +262,9 @@ All "speed" values are **delays in seconds** (smaller = faster). A creature runs
(combat decisions, target acquisition, spell timing).
- **Move clock**`ActiveMoveSpeed`/`PassiveMoveSpeed`/`CurrentMoveSpeed`: seconds per
step. Inherits the matching think value until overridden, so a creature configured with
only think speeds behaves as one clock. Any value is legal — steps are scheduled
independently of think ticks, so the two need not divide evenly.
only think speeds behaves as one clock. The properties read the raw override (`0` =
inheriting); `CurrentMoveSpeed` is the resolved pace. Any value is legal — steps are
scheduled independently of think ticks, so the two need not divide evenly.
Speeds normally come from `Distribution/Data/npc-speeds.json` (via `SpeedClass` or type
lists); `activeMove`/`passiveMove` are optional per bucket. Prefer data over code: