## 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.
527 lines
13 KiB
C#
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();
|
|
}
|
|
}
|