diff --git a/Projects/UOContent/Items/Food/Beverage.cs b/Projects/UOContent/Items/Food/Beverage.cs index 79b7f62fe..3a9f50297 100644 --- a/Projects/UOContent/Items/Food/Beverage.cs +++ b/Projects/UOContent/Items/Food/Beverage.cs @@ -240,6 +240,34 @@ public partial class Pitcher : BaseBeverage _ => 0 }; } + + /// + /// Intercepts pour actions targeting a WaterElemental to implement the Endless Decanter + /// acquisition mechanic (throw a full pitcher at the elemental for a 10% chance to receive the Endless Decanter). + /// + /// Coupling note: This override introduces a direct dependency from the generic Pitcher + /// class to a specific creature type. This is unavoidable because it is the regular Pitcher + /// and not the EndlessDecanter that the player throws. If a future creature ever uses a + /// similar pour interaction, this pattern should be revisited (e.g. an interface or event). + /// + /// + public override void Pour_OnTarget(Mobile from, object targ) + { + if (targ is not WaterElemental elemental) + { + base.Pour_OnTarget(from, targ); + return; + } + + if (Content == BeverageType.Water) + { + EndlessDecanter.HandleThrow(this, elemental, from); + } + else + { + from.SendLocalizedMessage(500846); // Can't pour it there. + } + } } [SerializationGenerator(2, false)] @@ -336,6 +364,7 @@ public abstract partial class BaseBeverage : Item, IHasQuantity if (itemID > 0) { ItemID = itemID; + OnQuantityChanged(); } else { @@ -344,6 +373,21 @@ public abstract partial class BaseBeverage : Item, IHasQuantity } } + /// + /// Called after is assigned a new value and the item ID has been updated, + /// but only when ComputeItemID returns a non-zero ID. If ComputeItemID + /// returns 0 (e.g. an empty Pitcher), the item is deleted and this method is + /// never called. + /// + /// Contract for subclasses: If you override this hook and need it to fire when the + /// beverage becomes empty, you must also override ComputeItemID" to return a + /// non-zero ID for the empty state (as EndlessDecanter" does). + /// + /// + protected virtual void OnQuantityChanged() + { + } + public abstract int ComputeItemID(); public virtual int GetQuantityDescription() diff --git a/Projects/UOContent/Items/Food/EndlessDecanter.cs b/Projects/UOContent/Items/Food/EndlessDecanter.cs new file mode 100644 index 000000000..a7703991e --- /dev/null +++ b/Projects/UOContent/Items/Food/EndlessDecanter.cs @@ -0,0 +1,222 @@ +using ModernUO.Serialization; +using Server.Collections; +using Server.ContextMenus; +using Server.Mobiles; +using Server.Targeting; + +namespace Server.Items; + +[SerializationGenerator(0)] +public partial class EndlessDecanter : Pitcher +{ + [InvalidateProperties] + [SerializableField(0)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _linked; + + [SerializableField(1)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Point3D _linkLocation; + + [SerializableField(2)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private Map _linkMap; + + [Constructible] + public EndlessDecanter() : base(BeverageType.Water) + { + Hue = 0x399; + LootType = LootType.Blessed; + } + + public override double DefaultWeight => 2.0; + + public override int LabelNumber => 1115929; // Endless Decanter of Water + + public override int ComputeItemID() => 0x0FF6; + + public override void GetContextMenuEntries(Mobile from, ref PooledRefList list) + { + base.GetContextMenuEntries(from, ref list); + + if (!from.Alive) + { + return; + } + + list.Add(new LinkEntry(this)); + + if (_linked) + { + list.Add(new UnlinkEntry(this)); + } + } + + public override void GetProperties(IPropertyList list) + { + base.GetProperties(list); + + list.Add(1115889); // Auto Water Refill + + if (_linked) + { + list.Add(1115893); // Linked + } + else + { + list.Add(1115894); // Unlinked + } + } + + /// + /// Triggered by the setter after every quantity change. + /// When the decanter becomes empty and the owner is within 10 tiles of the linked trough, + /// this method sets Quantity = MaxQuantity, which re-enters the Quantity setter + /// and calls OnQuantityChanged a second time. The second call is a no-op because + /// Quantity == 0 is false. Recursion depth is bounded to 2. + /// + protected override void OnQuantityChanged() + { + if (_linked && Content == BeverageType.Water && Quantity == 0 && RootParent is Mobile owner) + { + if (owner.Map == _linkMap && owner.InRange(_linkLocation, 10)) + { + Quantity = MaxQuantity; + owner.SendLocalizedMessage(1115901); // The decanter has automatically been filled from the linked water trough. + owner.PlaySound(0x4E); + } + else + { + owner.SendLocalizedMessage(1115972); // The decanter's refill attempt failed because the linked water trough is not in the area. + } + } + } + + public static void HandleThrow(Pitcher pitcher, WaterElemental elemental, Mobile thrower) + { + if (!pitcher.IsFull) + { + thrower.SendLocalizedMessage(1113038); // It is not full. + return; + } + + if (!thrower.InRange(elemental.Location, 5)) + { + thrower.SendLocalizedMessage(500295); // You are too far away to do that. + return; + } + + if (!elemental.HasDecanter) + { + thrower.SendLocalizedMessage(1115895); // It seems that this water elemental no longer has a magical decanter... + return; + } + + thrower.RevealingAction(); + elemental.Damage(1, thrower); + + if (0.1 > Utility.RandomDouble()) + { + elemental.HasDecanter = false; + pitcher.Delete(); + thrower.AddToBackpack(new EndlessDecanter()); + thrower.SendLocalizedMessage(1115897); // The water elemental has thrown a magical decanter back to you! + } + else + { + pitcher.Delete(); + thrower.PlaySound(0x040); + thrower.SendLocalizedMessage(1115896); // The water pitcher has shattered. + } + } + + private class LinkEntry : ContextMenuEntry + { + private readonly EndlessDecanter _decanter; + + public LinkEntry(EndlessDecanter decanter) : base(1115891, 0) => _decanter = decanter; // Link + + public override void OnClick(Mobile from, IEntity target) + { + if (_decanter.Deleted || !_decanter.Movable || !from.CheckAlive() || !_decanter.CheckItemUse(from)) + { + return; + } + + from.SendLocalizedMessage(1115892); // Target a water trough you wish to link. + from.BeginTarget(10, false, TargetFlags.None, Link_OnTarget, _decanter); + } + } + + private static void Link_OnTarget(Mobile from, object targeted, object state) + { + var decanter = (EndlessDecanter)state; + + if (decanter?.Deleted != false || !decanter.Movable || !from.CheckAlive() || !decanter.CheckItemUse(from)) + { + return; + } + + int itemID; + Point3D location; + Map map; + + if (targeted is StaticTarget st) + { + itemID = st.ItemID; + location = st.Location; + map = from.Map; + } + else if (targeted is Item item) + { + itemID = item.ItemID; + location = item.Location; + map = item.Map; + } + else + { + from.SendLocalizedMessage(1115900); // Invalid target. Please target a water trough. + return; + } + + if (itemID is >= 0xB41 and <= 0xB44) + { + decanter.Linked = true; + decanter.LinkLocation = location; + decanter.LinkMap = map; + + from.SendLocalizedMessage(1115899); // That water trough has been linked to this decanter. + + // If the decanter is already empty when linked, trigger an immediate refill attempt. + // OnQuantityChanged is called directly rather than through the Quantity setter because + // the quantity hasn't changed — we just want to evaluate the refill condition now that + // a trough location is known. + if (decanter.IsEmpty && decanter.Content == BeverageType.Water) + { + decanter.OnQuantityChanged(); + } + } + else + { + from.SendLocalizedMessage(1115900); // Invalid target. Please target a water trough. + } + } + + private class UnlinkEntry : ContextMenuEntry + { + private readonly EndlessDecanter _decanter; + + public UnlinkEntry(EndlessDecanter decanter) : base(1115930, 0) => _decanter = decanter; // Unlink + + public override void OnClick(Mobile from, IEntity target) + { + if (_decanter.Deleted || !_decanter.Movable || !from.CheckAlive() || !_decanter.CheckItemUse(from)) + { + return; + } + + from.SendLocalizedMessage(1115898); // The link between this decanter and the water trough has been removed. + _decanter.Linked = false; + } + } +} diff --git a/Projects/UOContent/Migrations/Server.Items.EndlessDecanter.v0.json b/Projects/UOContent/Migrations/Server.Items.EndlessDecanter.v0.json new file mode 100644 index 000000000..aef743a99 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Items.EndlessDecanter.v0.json @@ -0,0 +1,30 @@ +{ + "version": 0, + "type": "Server.Items.EndlessDecanter", + "properties": [ + { + "name": "Linked", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + }, + { + "name": "LinkLocation", + "type": "Server.Point3D", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Point3D" + ] + }, + { + "name": "LinkMap", + "type": "Server.Map", + "rule": "PrimitiveUOTypeMigrationRule", + "ruleArguments": [ + "Map" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Migrations/Server.Mobiles.WaterElemental.v1.json b/Projects/UOContent/Migrations/Server.Mobiles.WaterElemental.v1.json new file mode 100644 index 000000000..264a05cc8 --- /dev/null +++ b/Projects/UOContent/Migrations/Server.Mobiles.WaterElemental.v1.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "type": "Server.Mobiles.WaterElemental", + "properties": [ + { + "name": "HasDecanter", + "type": "bool", + "rule": "PrimitiveTypeMigrationRule", + "ruleArguments": [ + "" + ] + } + ] +} \ No newline at end of file diff --git a/Projects/UOContent/Mobiles/Monsters/Elemental/Magic/WaterElemental.cs b/Projects/UOContent/Mobiles/Monsters/Elemental/Magic/WaterElemental.cs index 840654980..b86d775b9 100644 --- a/Projects/UOContent/Mobiles/Monsters/Elemental/Magic/WaterElemental.cs +++ b/Projects/UOContent/Mobiles/Monsters/Elemental/Magic/WaterElemental.cs @@ -3,12 +3,17 @@ using Server.Items; namespace Server.Mobiles { - [SerializationGenerator(0, false)] + [SerializationGenerator(1, false)] public partial class WaterElemental : BaseCreature { + [SerializableField(0)] + [SerializedCommandProperty(AccessLevel.GameMaster)] + private bool _hasDecanter; + [Constructible] public WaterElemental() : base(AIType.AI_Mage) { + _hasDecanter = true; Body = 16; BaseSoundID = 278; @@ -56,5 +61,9 @@ namespace Server.Mobiles AddLoot(LootPack.Meager); AddLoot(LootPack.Potions); } + + private void MigrateFrom(V0Content content) + { + } } }