ModernUO/Projects/UOContent/Mobiles/Animals/Mounts/BaseMount.cs
Kamron Batman 843a29e7dc
refactor: master views over BaseCreature's one master reference (#2674)
## Summary

After #2670, `BaseCreature` stores one master. Callers still reached it through three differently gated names plus hand-rolled combinations of the `Controlled`/`Summoned` flags. Each gate is now one named view:

| Member | Definition | Meaning |
|---|---|---|
| `Master` | the reference; its setter does the follower bookkeeping; serialized as before | whoever the creature answers to |
| `ControlMaster` | `Controlled ? Master : null` | the owner |
| `SummonMaster` | `Summoned ? Master : null` | the summoner |
| `GetMaster()` | `ControlMaster ?? SummonMaster` | who answers for the creature |

`ControlMaster` and `SummonMaster` stay settable so custom code keeps compiling: both setters assign `Master`. There is no guard, so a write through either one can read back as null until the matching flag is set.

An enraged creature is the one shape with a master and neither flag. It keeps its meer through `Master`, while `GetMaster()` stays null, so the meer never answers for it (notoriety, kill credit).

Readers move onto the view they mean:
- "Owner or summoner" pairs and `ControlMaster ?? SummonMaster` collapse into `GetMaster()`: pack instinct, familiars, golem, house access, mounts, pack horse, Solen friendship, spell target and guild checks, and the `[` pet command.
- Checks such as `Controlled && ControlMaster == from` drop the flag the view already applies. Checks that don't reduce keep it: `Controlled && ControlMaster != from`, a possibly-null `from`, and `MageAI.CanDispel`'s `Summoned &&`.

**No behavior change, save format unchanged** (the schema generator produces no diff). Edge cases for custom code and shard operators:
- `SummonMaster = x` on a plain pet now replaces its owner (#2670 made that a no-op), and `ControlMaster = null` on an uncontrolled summon now clears its caster (it did nothing before).
- `SpellHelper.GetGuildFor` no longer falls back to an enraged creature's meer. Meer mages have no guild.

## Docs

- `dev-docs/content-patterns.md` § Masters: which view answers which question, how to set a master, and which flag checks are redundant.
- `dev-docs/runuo-migration-docs/09-items-mobiles-creatures.md` § Masters: RunUO's two-field reads mapped onto the views, plus the gotcha for creatures that set `SummonMaster` without `Summoned` (the write compiles, the read now returns null; use `Master`).
- The `modernuo-content-patterns` and `migrate-items-mobiles` skills point at both.

## Test plan

- [x] `MasterViewsTests`: all four views across wild, pet, controlled summon, uncontrolled summon and enraged shapes; reassigning `Master` moves follower slots; `SetControlMaster(null)` keeps an uncontrolled summon's caster; deleting a summon returns the caster's slots
- [x] Serialization tests updated to the new views (round-trip, legacy v22, v23 migration)
- [x] UOContent.Tests (1178 passed, 2 skipped) and Server.Tests (907) green
2026-09-24 22:45:24 -07:00

358 lines
8.5 KiB
C#

using ModernUO.Serialization;
using System;
using Server.Items;
using Server.Misc;
using Server.Multis;
using Server.Regions;
using Server.Targeting;
namespace Server.Mobiles;
[SerializationGenerator(0, false)]
public abstract partial class BaseMount : BaseCreature, IMount
{
public BaseMount(
int bodyID,
int itemID,
AIType aiType,
FightMode fightMode = FightMode.Closest,
int rangePerception = DefaultRangePerception,
int rangeFight = 1
) : base(aiType, fightMode, rangePerception, rangeFight)
{
Body = bodyID;
InternalItem = new MountItem(this, itemID);
}
public virtual TimeSpan MountAbilityDelay => TimeSpan.Zero;
[SerializableField(0)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
public DateTime _nextMountAbility;
[SerializableField(2)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
protected Item _internalItem;
public virtual bool AllowMaleRider => true;
public virtual bool AllowFemaleRider => true;
// Stamina System - 1 step per 1 second and 3840 steps max = 64 minutes
public virtual int StepsMax => 3840;
public virtual int StepsGainedPerIdleTime => 1;
public virtual TimeSpan IdleTimePerStepsGain => TimeSpan.FromSeconds(1);
[Hue]
[CommandProperty(AccessLevel.GameMaster)]
public override int Hue
{
get => base.Hue;
set
{
base.Hue = value;
if (InternalItem != null)
{
InternalItem.Hue = value;
}
}
}
[CommandProperty(AccessLevel.GameMaster)]
public int ItemID
{
get => InternalItem?.ItemID ?? 0;
set
{
if (InternalItem != null)
{
InternalItem.ItemID = value;
}
}
}
[CommandProperty(AccessLevel.GameMaster)]
[SerializableProperty(1)]
public Mobile Rider
{
get => _rider;
set
{
if (_rider == value)
{
return;
}
if (value == null)
{
var loc = _rider.Location;
var map = _rider.Map;
if (map == null || map == Map.Internal)
{
loc = _rider.LogoutLocation;
map = _rider.LogoutMap;
}
Direction = _rider.Direction;
Location = loc;
Map = map;
InternalItem?.Internalize();
}
else
{
if (_rider != null)
{
Dismount(_rider);
}
Dismount(value);
if (InternalItem != null)
{
value.AddItem(InternalItem);
}
value.Direction = Direction;
Internalize();
if (value.Target is Bola.BolaTarget)
{
Target.Cancel(value);
}
}
_rider = value;
if (value == null)
{
StaminaSystem.OnDismount(this);
}
this.MarkDirty();
}
}
public virtual void OnRiderDamaged(int amount, Mobile from, bool willKill)
{
if (_rider == null)
{
return;
}
var attacker = from ?? _rider.FindMostRecentDamager(true);
if (!(attacker == this || attacker == _rider || willKill || Core.Now < NextMountAbility)
&& DoMountAbility(amount, from))
{
NextMountAbility = Core.Now + MountAbilityDelay;
}
}
public override bool OnBeforeDeath()
{
Rider = null;
return base.OnBeforeDeath();
}
public override void OnAfterDelete()
{
InternalItem?.Delete();
InternalItem = null;
base.OnAfterDelete();
}
public override void OnDelete()
{
Rider = null;
base.OnDelete();
}
[AfterDeserialization(false)]
private void AfterDeserialize()
{
if (InternalItem == null)
{
Delete();
}
}
public virtual void OnDisallowedRider(Mobile m)
{
m.SendLocalizedMessage(1042317); // You may not ride at this time
}
public override void OnDoubleClick(Mobile from)
{
if (IsDeadPet)
{
return;
}
if (from.IsBodyMod && !from.Body.IsHuman)
{
if (Core.AOS) // You cannot ride a mount in your current form.
{
PrivateOverheadMessage(MessageType.Regular, 0x3B2, 1062061, from.NetState);
}
else
{
from.SendLocalizedMessage(1061628); // You can't do that while polymorphed.
}
return;
}
if (!CheckMountAllowed(from))
{
return;
}
if (from.Mounted)
{
from.SendLocalizedMessage(1005583); // Please dismount first.
return;
}
if (from.Female ? !AllowFemaleRider : !AllowMaleRider)
{
OnDisallowedRider(from);
return;
}
if (!DesignContext.Check(from))
{
return;
}
if (from.HasTrade)
{
from.SendLocalizedMessage(1042317, "", 0x41); // You may not ride at this time
return;
}
if (from.InRange(this, 1))
{
var canAccess = from.AccessLevel >= AccessLevel.GameMaster
|| GetMaster() == from;
if (canAccess)
{
if (Poisoned)
{
// This mount is too ill to ride.
PrivateOverheadMessage(MessageType.Regular, 0x3B2, 1049692, from.NetState);
}
else
{
Rider = from;
}
}
else if (!Controlled && !Summoned)
{
// That mount does not look broken! You would have to tame it to ride it.
PrivateOverheadMessage(MessageType.Regular, 0x3B2, 501263, from.NetState);
}
else
{
// This isn't your mount; it refuses to let you ride.
PrivateOverheadMessage(MessageType.Regular, 0x3B2, 501264, from.NetState);
}
}
else
{
from.SendLocalizedMessage(500206); // That is too far away to ride.
}
}
public static void Dismount(Mobile m)
{
var mount = m.Mount;
if (mount != null)
{
mount.Rider = null;
}
}
public static bool CheckMountAllowed(Mobile mob)
{
var result = true;
if (mob is PlayerMobile mobile)
{
if (mobile.Region is BaseRegion { MountsAllowed: false })
{
mobile.SendLocalizedMessage(1042317); // You may not ride at this time
result = false;
}
if (mobile.MountBlockReason != BlockMountType.None)
{
mobile.SendLocalizedMessage((int)mobile.MountBlockReason);
result = false;
}
if (mobile.Race == Race.Gargoyle)
{
mobile.PrivateOverheadMessage(MessageType.Regular, mobile.SpeechHue, 1112281, mobile.NetState); // Gargoyles are unable to ride animals.
result = false;
}
}
return result;
}
public virtual bool DoMountAbility(int damage, Mobile attacker) => false;
}
[SerializationGenerator(0, false)]
public partial class MountItem : Item, IMountItem
{
private BaseMount _mount;
public MountItem(BaseMount mount, int itemID) : base(itemID)
{
Layer = Layer.Mount;
Movable = false;
_mount = mount;
}
public override double DefaultWeight => 0;
[SerializableProperty(0, useField: nameof(_mount))]
public IMount Mount => _mount;
public override void OnAfterDelete()
{
_mount?.Delete();
_mount = null;
base.OnAfterDelete();
}
public override DeathMoveResult OnParentDeath(Mobile parent)
{
if (_mount != null)
{
_mount.Rider = null;
}
return DeathMoveResult.RemainEquipped;
}
[AfterDeserialization(false)]
private void AfterDeserialize()
{
if (_mount == null)
{
Delete();
}
}
}