ModernUO/Projects/UOContent/Mobiles/Vendors/Barkeeper/PlayerBarkeeper.cs
Kamron Batman b042edcf0b
refactor: fold hand-written serializable property setters into field hooks (#2587)
## Summary

Folds **113** hand-written `[SerializableProperty]` members into plain `[SerializableField]` declarations using the v4 setter hooks — value coercion/vetoes via `allowFieldChange`, post-change side effects via `fieldChanged` (whose `oldValue` parameter covers the old-house/old-sender unsubscribe patterns). Net **-450 lines** of setter boilerplate.

```cs
// before
[SerializableProperty(1)]
[CommandProperty(AccessLevel.GameMaster)]
public int Charges
{
    get => _charges;
    set
    {
        _charges = Math.Clamp(value, 0, MaxCharges);
        InvalidateProperties();
        this.MarkDirty();
    }
}

// after
[SerializableField(1, allowFieldChange: nameof(AllowChargesChange))]
[SerializedCommandProperty(AccessLevel.GameMaster)]
[InvalidateProperties]
private int _charges;

private bool AllowChargesChange(ref int value)
{
    value = Math.Clamp(value, 0, MaxCharges);
    return true;
}
```

## How sites were selected

A classifier parsed all 204 `[SerializableProperty]` sites and converted only those matching strict shapes: getter is exactly `get => _field;`, the assignment comes first (after at most an equality guard), and relocated side effects contain no `return`, no `value` mutation, and no field re-assignment. Everything else was left alone deliberately:

- **~34 custom getters** (fallback defaults like `_x == -1 ? Default : _x`, self-healing refs) — no setter hook can express these.
- **~35 pre-assignment logic** (durability Unscale/Scale sandwiches, old-state captures like PotionKeg's pile weight).
- **virtual/override members, name-mismatched backing fields (`m_`), exotic semantics** (guards' `Focus` does work on *equal* assignment; `ChampionSpawn.Active` never assigns its field).

Five sites the classifier refused were converted by hand where the hooks fit cleanly: `ReceiverCrystal.Sender`, `PlayerVendor.House`, `PlayerBarkeeper.House` (old-value unsubscribe via `oldValue`), `BaseSuit.AccessLevel` (its existing virtual `OnAccessLevelChanged` already had the exact callback shape), and `DyeTub.DyedHue` (a true veto: `AllowDyedHueChange(ref int value) => _redyable`).

## Verification

- Build: **0 errors, 0 warnings**.
- **Schema regeneration produces zero Migrations changes** — the conversion is wire- and schema-neutral by construction (same orders, types, and property names), and CI's schema diff check enforces it.
- **835 + 708 tests green.**

## Behavioral notes (all strict improvements, called out for review)

- Generated setters skip everything when the incoming value equals the current one; a few converted setters previously re-ran side effects on equal assignment (redundant `Update()`-style refreshes).
- Generated setters always `MarkDirty()` on change; several converted setters never did (e.g. `DyeTub.DyedHue`, `MorphItem` ranges) — their changes only persisted if something else dirtied the entity. Those latent persistence bugs are fixed by construction.
2026-08-22 18:20:39 -07:00

527 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using ModernUO.Serialization;
using Server.Collections;
using Server.ContextMenus;
using Server.Gumps;
using Server.Items;
using Server.Multis;
using Server.Prompts;
namespace Server.Mobiles;
public class ChangeRumorMessagePrompt : Prompt
{
private readonly PlayerBarkeeper _barkeeper;
private readonly int _rumorIndex;
public ChangeRumorMessagePrompt(PlayerBarkeeper barkeeper, int rumorIndex)
{
_barkeeper = barkeeper;
_rumorIndex = rumorIndex;
}
public override void OnCancel(Mobile from)
{
OnResponse(from, "");
}
public override void OnResponse(Mobile from, string text)
{
if (text.Length > 130)
{
text = text[..130];
}
_barkeeper.EndChangeRumor(from, _rumorIndex, text);
}
}
public class ChangeRumorKeywordPrompt : Prompt
{
private readonly PlayerBarkeeper _barkeeper;
private readonly int _rumorIndex;
public ChangeRumorKeywordPrompt(PlayerBarkeeper barkeeper, int rumorIndex)
{
_barkeeper = barkeeper;
_rumorIndex = rumorIndex;
}
public override void OnCancel(Mobile from)
{
OnResponse(from, "");
}
public override void OnResponse(Mobile from, string text)
{
if (text.Length > 130)
{
text = text[..130];
}
_barkeeper.EndChangeKeyword(from, _rumorIndex, text);
}
}
public class ChangeTipMessagePrompt : Prompt
{
private readonly PlayerBarkeeper _barkeeper;
public ChangeTipMessagePrompt(PlayerBarkeeper barkeeper) => _barkeeper = barkeeper;
public override void OnCancel(Mobile from)
{
OnResponse(from, "");
}
public override void OnResponse(Mobile from, string text)
{
if (text.Length > 130)
{
text = text[..130];
}
_barkeeper.EndChangeTip(from, text);
}
}
[SerializationGenerator(0)]
public partial class BarkeeperRumor
{
[DirtyTrackingEntity]
private PlayerBarkeeper _barkeeper;
[SerializableField(0)]
private string _message;
[SerializableField(1)]
private string _keyword;
public BarkeeperRumor(PlayerBarkeeper barkeeper, string message, string keyword)
{
_message = message;
_keyword = keyword;
_barkeeper = barkeeper;
}
public BarkeeperRumor(PlayerBarkeeper barkeeper) => _barkeeper = barkeeper;
}
public class ManageBarkeeperEntry : ContextMenuEntry
{
public ManageBarkeeperEntry() : base(6151, 12)
{
}
public override void OnClick(Mobile from, IEntity target)
{
(target as PlayerBarkeeper)?.BeginManagement(from);
}
}
[SerializationGenerator(2, false)]
public partial class PlayerBarkeeper : BaseVendor
{
public static readonly BarkeeperRumor[] EmptyRumors = new BarkeeperRumor[RumorCount];
public const int RumorCount = 3;
private readonly List<SBInfo> _sbInfos = [];
[SerializableField(1)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private Mobile _owner;
[SerializableField(2)]
private BarkeeperRumor[] _rumors;
[SerializableField(3)]
[SerializedCommandProperty(AccessLevel.GameMaster)]
private string _tipMessage;
private Timer _newsTimer;
public PlayerBarkeeper(Mobile owner, BaseHouse house) : base("the barkeeper")
{
Owner = owner;
House = house;
LoadSBInfo();
}
[SerializableField(0, fieldChanged: nameof(OnHouseChanged))]
private BaseHouse _house;
private void OnHouseChanged(BaseHouse oldValue, BaseHouse newValue)
{
oldValue?.PlayerBarkeepers.Remove(this);
newValue?.PlayerBarkeepers.Add(this);
}
public override bool IsActiveBuyer => false;
public override bool IsActiveSeller => _sbInfos.Count > 0;
public override bool DisallowAllMoves => true;
public override bool NoHouseRestrictions => true;
public override VendorShoeType ShoeType => Utility.RandomBool() ? VendorShoeType.ThighBoots : VendorShoeType.Boots;
protected override List<SBInfo> SBInfos => _sbInfos;
public override bool GetGender() => false;
public override void InitOutfit()
{
base.InitOutfit();
AddItem(new HalfApron(Utility.RandomBrightHue()));
var pack = Backpack;
pack?.Delete();
}
public override void InitBody()
{
base.InitBody();
Hue = Body == 0x340 || Body == 0x402 ? 0 : 0x83F4; // hue is not random
Backpack?.Delete();
}
public override bool HandlesOnSpeech(Mobile from) => InRange(from, 3) || base.HandlesOnSpeech(from);
private void ShoutNews_Callback(TownCrierEntry tce)
{
var index = _newsTimer.Index;
if (index >= tce.Lines.Length)
{
_newsTimer.Stop();
_newsTimer = null;
}
else
{
PublicOverheadMessage(MessageType.Regular, 0x3B2, false, tce.Lines[index]);
}
}
public override void OnAfterDelete()
{
base.OnAfterDelete();
House = null;
}
public override bool OnBeforeDeath()
{
if (!base.OnBeforeDeath())
{
return false;
}
var shoes = FindItemOnLayer(Layer.Shoes);
if (shoes is Sandals)
{
shoes.Hue = 0;
}
return true;
}
public override void OnSpeech(SpeechEventArgs e)
{
base.OnSpeech(e);
if (e.Handled || !InRange(e.Mobile, 3))
{
return;
}
if (_newsTimer == null && e.HasKeyword(0x30)) // *news*
{
var tce = GlobalTownCrierEntryList.Instance.GetRandomEntry();
if (tce == null)
{
PublicOverheadMessage(MessageType.Regular, 0x3B2, 1005643); // I have no news at this time.
}
else
{
_newsTimer = Timer.DelayCall(
TimeSpan.FromSeconds(1.0),
TimeSpan.FromSeconds(3.0),
tce.Lines.Length,
() => ShoutNews_Callback(tce)
);
PublicOverheadMessage(MessageType.Regular, 0x3B2, 502978); // Some of the latest news!
}
}
if (_rumors == null)
{
return;
}
for (var i = 0; i < _rumors.Length; ++i)
{
var rumor = _rumors[i];
var keyword = rumor?.Keyword;
if (keyword == null || (keyword = keyword.Trim()).Length == 0)
{
continue;
}
if (keyword.InsensitiveEquals(e.Speech))
{
var message = rumor.Message;
if (message == null || (message = message.Trim()).Length == 0)
{
continue;
}
PublicOverheadMessage(MessageType.Regular, 0x3B2, false, message);
}
}
}
public override bool CheckGold(Mobile from, Item dropped)
{
if (dropped is not Gold g)
{
return false;
}
if (g.Amount > 50)
{
PrivateOverheadMessage(
MessageType.Regular,
0x3B2,
false,
"I cannot accept so large a tip!",
from.NetState
);
}
else
{
var tip = TipMessage;
if (tip == null || (tip = tip.Trim()).Length == 0)
{
PrivateOverheadMessage(
MessageType.Regular,
0x3B2,
false,
"It would not be fair of me to take your money and not offer you information in return.",
from.NetState
);
}
else
{
Direction = GetDirectionTo(from);
PrivateOverheadMessage(MessageType.Regular, 0x3B2, false, tip, from.NetState);
g.Delete();
return true;
}
}
return false;
}
public bool IsOwner(Mobile from) =>
from?.Deleted == false && !Deleted && (from.AccessLevel > AccessLevel.GameMaster || Owner == from);
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
{
base.GetContextMenuEntries(from, ref list);
if (IsOwner(from) && from.InLOS(this))
{
list.Add(new ManageBarkeeperEntry());
}
}
public void BeginManagement(Mobile from)
{
if (!IsOwner(from))
{
return;
}
BarkeeperGump.DisplayTo(from, this);
}
public void Dismiss()
{
Delete();
}
public void BeginChangeRumor(Mobile from, int index)
{
if (index is < 0 or >= RumorCount)
{
return;
}
from.Prompt = new ChangeRumorMessagePrompt(this, index);
PrivateOverheadMessage(
MessageType.Regular,
0x3B2,
false,
"Say what news you would like me to tell our guests.",
from.NetState
);
}
public void EndChangeRumor(Mobile from, int index, string text)
{
if (index is < 0 or >= RumorCount)
{
return;
}
Rumors ??= new BarkeeperRumor[RumorCount];
if (Rumors[index] == null)
{
Rumors[index] = new BarkeeperRumor(this, text, null);
}
else
{
Rumors[index].Message = text;
}
this.MarkDirty();
from.Prompt = new ChangeRumorKeywordPrompt(this, index);
// What keyword should a guest say to me to get this news?
PrivateOverheadMessage(MessageType.Regular, 0x3B2, 1079267, from.NetState);
}
public void EndChangeKeyword(Mobile from, int index, string text)
{
if (index is < 0 or >= RumorCount)
{
return;
}
Rumors ??= new BarkeeperRumor[RumorCount];
if (Rumors[index] == null)
{
Rumors[index] = new BarkeeperRumor(this, null, text);
}
else
{
Rumors[index].Keyword = text;
}
this.MarkDirty();
PrivateOverheadMessage(MessageType.Regular, 0x3B2, false, "I'll pass on the message.", from.NetState);
}
public void RemoveRumor(int index)
{
if (index is >= 0 and < RumorCount && _rumors != null)
{
_rumors[index] = null;
this.MarkDirty();
}
}
public void BeginChangeTip(Mobile from)
{
from.Prompt = new ChangeTipMessagePrompt(this);
// Say what you want me to tell guests when they give me a good tip.
PrivateOverheadMessage(MessageType.Regular, 0x3B2, 1079268, from.NetState);
}
public void EndChangeTip(Mobile from, string text)
{
TipMessage = text;
PrivateOverheadMessage(
MessageType.Regular,
0x3B2,
false,
"I'll say that to anyone who gives me a good tip.",
from.NetState
);
}
public void RemoveTip() => TipMessage = null;
public void BeginChangeTitle(Mobile from) => from.SendGump(new BarkeeperTitleGump(this));
public void EndChangeTitle(string title)
{
Title = title;
LoadSBInfo();
}
public void CancelChangeTitle(Mobile from) => BarkeeperGump.DisplayTo(from, this);
public void BeginChangeAppearance(Mobile from)
{
PlayerVendorCustomizeGump.DisplayTo(from, this);
}
public void ChangeGender()
{
Female = !Female;
if (Female)
{
Body = 401;
Name = NameList.RandomName("female");
FacialHairItemID = 0;
}
else
{
Body = 400;
Name = NameList.RandomName("male");
}
}
public override void InitSBInfo()
{
if (Title is not ("the waiter" or "the barkeeper" or "the baker" or "the innkeeper" or "the chef"))
{
_sbInfos.Clear();
return;
}
if (_sbInfos.Count == 0)
{
_sbInfos.Add(new SBPlayerBarkeeper());
}
}
private void Deserialize(IGenericReader reader, int version)
{
_house = (BaseHouse)reader.ReadEntity<Item>();
_owner = reader.ReadEntity<Mobile>();
_rumors = new BarkeeperRumor[reader.ReadEncodedInt()];
for (var i = 0; i < _rumors.Length; ++i)
{
if (reader.ReadBool())
{
_rumors[i] = new BarkeeperRumor(this)
{
Message = reader.ReadString(),
Keyword = reader.ReadString()
};
}
}
_tipMessage = reader.ReadString();
}
}