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