## The bug
`ObjectPropertyList.AddHash` folded each cliloc and a Marvin hash of each argument together with XOR, which is both commutative and self-inverse. Any value mixed in an even number of times cancelled outright, so two properties sharing one argument hashed identically no matter what that argument was:
```
_hash = 31659021
AddHash(1063752); AddHash(hash("10"))
AddHash(1063737); AddHash(hash("10")) // the argument cancels here
AddHash(1063740) // -> 32714560, for ANY argument
```
The 10% and 5% variants of the same item therefore produced the same revision. The client caches the tooltip by revision and only re-requests when it changes, so it kept rendering the stale percentage.
Same root cause, three more shapes:
| | old | new |
|---|---|---|
| Two properties sharing an argument | collides | distinct |
| Properties reordered | collides | distinct |
| Two properties trading arguments | collides | distinct |
| Argument-less property added twice | cancels to 0 | distinct |
## The fix
Hash the finished property block in `Terminate()` instead of accumulating per property. Those bytes — cliloc, length prefix and UTF-16 text, in emission order — are the exact content the client renders, so anything that changes the tooltip changes the hash. The length prefix also removes the concatenation ambiguity the old scheme had.
`AddHash` and both `string.GetHashCode` calls are gone.
## Why 26 bits, and why not 64
The client never recomputes the hash — it stores what we send in 0xD6 and compares it for equality against the 0xDC revision (`ObjectPropertiesListManager.IsRevisionEquals`). So the algorithm is ours to choose, but the width is not:
- Both packets carry a **4-byte** revision, so 64 bits is not available. A wider internal value would be worse than useless: the server would see a change and send a 0xDC whose truncated 32 bits are identical, and the client still wouldn't refresh.
- `Terminate` writes the bare hash into 0xD6 while `SendOPLInfo` writes `Hash` with bit 30 set. The client recovers one from the other by masking off `0x40000000`, which only holds while the hash stays below that bit. Hence 26 bits, unchanged from before.
- `Hash => 0x40000000 + _hash` keeps the revision non-zero; the client parks 0 as its "nothing cached" sentinel.
## Collision behaviour
Enumerated real small-OPL spaces and counted 26-bit collisions against the birthday expectation for a uniform hash:
| Scenario | block | n | collisions | expected |
|---|---|---|---|---|
| 1 cliloc, no arg (every cliloc 1.0M–3.2M) | 6 B | 2,200,000 | 35,591 | ~36,061 |
| 1 cliloc + numeric arg 0–199,999 | 8–12 B | 200,000 | 300 | ~298 |
| 2 clilocs, both with numeric args | 12–24 B | 202,500 | 288 | ~306 |
| 1 cliloc + 1-char arg | 8 B | 4,000,000 | 116,291 | ~119,209 |
Every case lands on the random-model line, and per-bit P(1) across all 26 bits is 0.4962–0.5024 — XXH3's short-input paths still avalanche fully, so a 6-byte block behaves like a uniform 26-bit draw. Masking the low 26 bits versus folding all 64 down was a wash (35,591 vs 35,558).
The metric that matters is narrower, since the client compares a revision only against the previous revision **for the same serial**: 2^-26 = 1.5e-8 per genuine tooltip change. Consecutive-value transitions (charges 50 -> 49) collided 0 times in 200,000.
Row 3 is the old scheme quantified: it collapsed 202,500 inputs into 100,954 distinct hashes, a 50% collision rate — systematic, not probabilistic.
One honest regression: in row 1 the old scheme had zero collisions, because for a single argument-less cliloc under 2^26 the hash was the identity function. An item whose whole tooltip is one argument-less cliloc changing to a different one goes from never colliding to 1.5e-8.
## Performance
Cheaper, not just correct — one xxHash3 pass replaces a Marvin hash per string property over those same bytes. A 12-property, 302-byte tooltip, steady state:
```
old (XOR + Marvin per property) 70.5 ns
xxHash3 (streaming, HashUtility) 27.0 ns
```
`XxHash3.HashToUInt64` one-shot is a further ~7ns faster and produces byte-identical output, but taking it would mean editing `HashUtility.ComputeHash64`, whose values are baked into save files via `AssemblyHandler.GetTypeHash`. Not worth it.
## Second commit: plant old-client property list
Found while tracing the `Hash` read sites. `PlantItem.OldClientPropertyList` called `InitializePropertyList` on every access instead of only when the list was null, and without a `Reset`. Every read appended another copy of every property to the same buffer. `SendOPLPacketTo` and `SendPropertiesTo` both go through the getter, so a pre-7.0.12 client looking at a plant grew the buffer without bound and moved the revision on reads alone.
Now builds once, matching `Item.PropertyList`. `InvalidateProperties` already `Reset`s before rebuilding.
## Testing
`Server.Tests` 869 passed, `UOContent.Tests` 779 passed.
New regression tests, each confirmed failing against the old code first:
- `RepeatedArgument_DoesNotCancelOut` — the reported case
- `PropertyOrder_ChangesHash`, `SwappedArguments_ChangeHash`, `DuplicateProperty_ChangesHash`
- `ShortNumericArguments_ConsecutiveValuesDiffer`, `SmallPropertyBlocks_StayWellDistributed` — short-input avalanche, bounded loosely enough to hold for any seed
- `Hash_StaysWithinTheRevisionMask`, `EmptyList_IsNonZeroAndDistinctFromPopulated` — wire constraints
- `PlantItemPropertyListTests.OldClientPropertyList_BuildsOnceAndIsStableAcrossReads`
712 lines
19 KiB
C#
712 lines
19 KiB
C#
using System.Collections.Generic;
|
|
using ModernUO.Serialization;
|
|
using Server.Collections;
|
|
using Server.ContextMenus;
|
|
using Server.Gumps;
|
|
using Server.Items;
|
|
using Server.Multis;
|
|
using Server.Network;
|
|
|
|
namespace Server.Engines.Plants;
|
|
|
|
public enum PlantStatus
|
|
{
|
|
BowlOfDirt = 0,
|
|
Seed = 1,
|
|
Sapling = 2,
|
|
Plant = 4,
|
|
FullGrownPlant = 7,
|
|
DecorativePlant = 10,
|
|
DeadTwigs = 11,
|
|
|
|
Stage1 = 1,
|
|
Stage2 = 2,
|
|
Stage3 = 3,
|
|
Stage4 = 4,
|
|
Stage5 = 5,
|
|
Stage6 = 6,
|
|
Stage7 = 7,
|
|
Stage8 = 8,
|
|
Stage9 = 9
|
|
}
|
|
|
|
[SerializationGenerator(3, false)]
|
|
public partial class PlantItem : Item, ISecurable
|
|
{
|
|
public static List<PlantItem> Plants { get; } = [];
|
|
|
|
[SerializedIgnoreDupe]
|
|
[SerializableField(0)]
|
|
[SaveFlag(nameof(ShouldSerializeSecureLevel))]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private SecureLevel _level;
|
|
|
|
private bool ShouldSerializeSecureLevel() => (int)_level != 0;
|
|
|
|
[SerializedIgnoreDupe]
|
|
[SerializableField(5, setter: "private")]
|
|
[SaveFlag(nameof(ShouldSerializePlantSystem))]
|
|
private PlantSystem _plantSystem;
|
|
|
|
private bool ShouldSerializePlantSystem() => _plantStatus < PlantStatus.DecorativePlant;
|
|
|
|
// For clients older than 7.0.12.0
|
|
private ObjectPropertyList _oldClientPropertyList;
|
|
|
|
[Constructible]
|
|
public PlantItem(bool fertileDirt = false) : base(0x1602)
|
|
{
|
|
_plantStatus = PlantStatus.BowlOfDirt;
|
|
_plantSystem = new PlantSystem(this)
|
|
{
|
|
FertileDirt = fertileDirt
|
|
};
|
|
|
|
_level = SecureLevel.Owner;
|
|
|
|
Plants.Add(this);
|
|
}
|
|
|
|
public override double DefaultWeight => 1.0;
|
|
|
|
public ObjectPropertyList OldClientPropertyList
|
|
{
|
|
get
|
|
{
|
|
// Build once, like Item.PropertyList. Initializing on every read appended another copy
|
|
// of every property to the same list. InvalidateProperties rebuilds it, Reset first.
|
|
if (_oldClientPropertyList == null)
|
|
{
|
|
var list = new ObjectPropertyList(this);
|
|
_oldClientPropertyList = list;
|
|
InitializePropertyList(list);
|
|
}
|
|
|
|
return _oldClientPropertyList;
|
|
}
|
|
}
|
|
|
|
public override bool ForceShowProperties => ObjectPropertyList.Enabled;
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
[SerializableProperty(1)]
|
|
[SaveFlag(nameof(ShouldSerializePlantStatus))]
|
|
public PlantStatus PlantStatus
|
|
{
|
|
get => _plantStatus;
|
|
set
|
|
{
|
|
if (_plantStatus == value || value is < PlantStatus.BowlOfDirt or > PlantStatus.DeadTwigs)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var ratio = PlantSystem != null ? (double)PlantSystem.Hits / PlantSystem.MaxHits : 1.0;
|
|
|
|
_plantStatus = value;
|
|
this.MarkDirty();
|
|
|
|
if (_plantStatus >= PlantStatus.DecorativePlant)
|
|
{
|
|
PlantSystem = null;
|
|
}
|
|
else
|
|
{
|
|
PlantSystem ??= new PlantSystem(this);
|
|
|
|
var hits = (int)(PlantSystem.MaxHits * ratio);
|
|
|
|
if (hits == 0 && _plantStatus > PlantStatus.BowlOfDirt)
|
|
{
|
|
PlantSystem.Hits = 1;
|
|
}
|
|
else
|
|
{
|
|
PlantSystem.Hits = hits;
|
|
}
|
|
}
|
|
|
|
Update();
|
|
}
|
|
}
|
|
|
|
private bool ShouldSerializePlantStatus() => _plantStatus != PlantStatus.BowlOfDirt;
|
|
|
|
[SerializableField(2, fieldChanged: nameof(OnPlantTypeChanged))]
|
|
[SaveFlag(nameof(ShouldSerializePlantType))]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private PlantType _plantType;
|
|
|
|
private void OnPlantTypeChanged(PlantType oldValue, PlantType newValue)
|
|
{
|
|
Update();
|
|
}
|
|
|
|
private bool ShouldSerializePlantType() => (int)_plantType != 0;
|
|
|
|
[SerializableField(3, fieldChanged: nameof(OnPlantHueChanged))]
|
|
[SaveFlag(nameof(ShouldSerializePlantHue))]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
private PlantHue _plantHue;
|
|
|
|
private void OnPlantHueChanged(PlantHue oldValue, PlantHue newValue)
|
|
{
|
|
Update();
|
|
}
|
|
|
|
private bool ShouldSerializePlantHue() => _plantHue != PlantHue.None;
|
|
|
|
[SerializableField(4)]
|
|
[SaveFlag(nameof(ShouldSerializeShowType))]
|
|
[SerializedCommandProperty(AccessLevel.GameMaster)]
|
|
[InvalidateProperties]
|
|
private bool _showType;
|
|
|
|
private bool ShouldSerializeShowType() => _showType;
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public bool ValidGrowthLocation
|
|
{
|
|
get
|
|
{
|
|
if (IsLockedDown && RootParent == null)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (RootParent is not Mobile owner)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsChildOf(owner.Backpack) || IsChildOf(owner.FindBankNoCreate());
|
|
}
|
|
}
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public bool IsGrowable => _plantStatus >= PlantStatus.BowlOfDirt && _plantStatus <= PlantStatus.Stage9;
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public bool IsCrossable => PlantHueInfo.IsCrossable(PlantHue) && PlantTypeInfo.IsCrossable(PlantType);
|
|
|
|
[CommandProperty(AccessLevel.GameMaster)]
|
|
public bool Reproduces => PlantHueInfo.CanReproduce(PlantHue) && PlantTypeInfo.CanReproduce(PlantType);
|
|
|
|
public override void OnAfterDuped(Item newItem)
|
|
{
|
|
PlantSystem.OnAfterDuped(newItem);
|
|
}
|
|
|
|
public override void OnSingleClick(Mobile from)
|
|
{
|
|
if (_plantStatus >= PlantStatus.DeadTwigs)
|
|
{
|
|
LabelTo(from, LabelNumber);
|
|
}
|
|
else if (_plantStatus >= PlantStatus.DecorativePlant)
|
|
{
|
|
LabelTo(from, 1061924); // a decorative plant
|
|
}
|
|
else if (_plantStatus >= PlantStatus.FullGrownPlant)
|
|
{
|
|
LabelTo(from, PlantTypeInfo.GetInfo(_plantType).Name);
|
|
}
|
|
else
|
|
{
|
|
LabelTo(from, 1029913); // plant bowl
|
|
}
|
|
}
|
|
|
|
public override void GetContextMenuEntries(Mobile from, ref PooledRefList<ContextMenuEntry> list)
|
|
{
|
|
base.GetContextMenuEntries(from, ref list);
|
|
SetSecureLevelEntry.AddTo(from, this, ref list);
|
|
}
|
|
|
|
public int GetLocalizedPlantStatus()
|
|
{
|
|
return _plantStatus switch
|
|
{
|
|
>= PlantStatus.Plant => 1060812, // plant
|
|
>= PlantStatus.Sapling => 1023305, // sapling
|
|
>= PlantStatus.Seed => 1060810, // seed
|
|
_ => 1026951 // dirt
|
|
};
|
|
}
|
|
|
|
public static int GetLocalizedContainerType() => 1150435;
|
|
|
|
private void Update()
|
|
{
|
|
if (_plantStatus >= PlantStatus.DeadTwigs)
|
|
{
|
|
ItemID = 0x1B9D;
|
|
Hue = PlantHueInfo.GetInfo(_plantHue).Hue;
|
|
}
|
|
else if (_plantStatus >= PlantStatus.FullGrownPlant)
|
|
{
|
|
ItemID = PlantTypeInfo.GetInfo(_plantType).ItemID;
|
|
Hue = PlantHueInfo.GetInfo(_plantHue).Hue;
|
|
}
|
|
else if (_plantStatus >= PlantStatus.Plant)
|
|
{
|
|
ItemID = 0x1600;
|
|
Hue = 0;
|
|
}
|
|
else
|
|
{
|
|
ItemID = 0x1602;
|
|
Hue = 0;
|
|
}
|
|
|
|
InvalidateProperties();
|
|
this.MarkDirty();
|
|
}
|
|
|
|
private void InitializePropertyList(ObjectPropertyList list)
|
|
{
|
|
GetProperties(list);
|
|
AppendChildProperties(list);
|
|
list.Terminate();
|
|
}
|
|
|
|
// Overridden to support new and old client localization
|
|
public override void SendOPLPacketTo(NetState ns)
|
|
{
|
|
if (!ObjectPropertyList.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (ns.Version < ClientVersion.Version70120)
|
|
{
|
|
ns.SendOPLInfo(Serial, OldClientPropertyList.Hash);
|
|
return;
|
|
}
|
|
|
|
ns.SendOPLInfo(this);
|
|
}
|
|
|
|
public override void SendPropertiesTo(NetState ns)
|
|
{
|
|
if (ns?.Version < ClientVersion.Version70120)
|
|
{
|
|
ns?.Send(OldClientPropertyList.Buffer);
|
|
return;
|
|
}
|
|
|
|
ns?.Send(PropertyList.Buffer);
|
|
}
|
|
|
|
public override void ClearProperties()
|
|
{
|
|
base.ClearProperties();
|
|
_oldClientPropertyList = null;
|
|
}
|
|
|
|
public override void InvalidateProperties()
|
|
{
|
|
base.InvalidateProperties();
|
|
|
|
if (!ObjectPropertyList.Enabled)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Map != null && Map != Map.Internal && !World.Loading)
|
|
{
|
|
int? oldHash;
|
|
int newHash;
|
|
if (_oldClientPropertyList != null)
|
|
{
|
|
oldHash = _oldClientPropertyList.Hash;
|
|
_oldClientPropertyList.Reset();
|
|
InitializePropertyList(_oldClientPropertyList);
|
|
newHash = _oldClientPropertyList.Hash;
|
|
}
|
|
else
|
|
{
|
|
oldHash = null;
|
|
newHash = OldClientPropertyList.Hash;
|
|
}
|
|
|
|
if (oldHash != newHash)
|
|
{
|
|
Delta(ItemDelta.Properties);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
ClearProperties();
|
|
}
|
|
}
|
|
|
|
public override void OnAosSingleClick(Mobile from)
|
|
{
|
|
var ns = from?.NetState;
|
|
|
|
if (ns == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var opl = ns.Version < ClientVersion.Version70120 ? OldClientPropertyList : PropertyList;
|
|
|
|
if (opl.Header > 0)
|
|
{
|
|
from.NetState.SendMessageLocalized(
|
|
Serial,
|
|
ItemID,
|
|
MessageType.Label,
|
|
0x3B2,
|
|
3,
|
|
opl.Header,
|
|
Name,
|
|
opl.HeaderArgs
|
|
);
|
|
}
|
|
}
|
|
|
|
public override void GetProperties(IPropertyList list)
|
|
{
|
|
if (_plantStatus >= PlantStatus.DeadTwigs)
|
|
{
|
|
base.GetProperties(list);
|
|
return;
|
|
}
|
|
|
|
var typeInfo = PlantTypeInfo.GetInfo(_plantType);
|
|
var hueInfo = PlantHueInfo.GetInfo(_plantHue);
|
|
|
|
if (_plantStatus >= PlantStatus.DecorativePlant)
|
|
{
|
|
list.Add(typeInfo.GetPlantLabelDecorative(hueInfo), $"{hueInfo.Name:#}\t{typeInfo.Name:#}");
|
|
return;
|
|
}
|
|
|
|
var container = GetLocalizedContainerType();
|
|
var dirt = PlantSystem.GetLocalizedDirtStatus();
|
|
var health = PlantSystem.GetLocalizedHealth();
|
|
var plantStatus = GetLocalizedPlantStatus();
|
|
|
|
if (_plantStatus < PlantStatus.Seed)
|
|
{
|
|
// Clients above 7.0.12.0 use the regular PropertyList
|
|
if (list != _oldClientPropertyList)
|
|
{
|
|
// a ~1_val~ of ~2_val~ dirt
|
|
list.Add(1060830, $"{container:#}\t{dirt:#}");
|
|
}
|
|
else
|
|
{
|
|
// a ~1_val~ of ~2_val~ dirt
|
|
list.Add(1060830, $"{dirt:#}");
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_plantStatus >= PlantStatus.FullGrownPlant)
|
|
{
|
|
list.Add(
|
|
typeInfo.GetPlantLabelFullGrown(hueInfo),
|
|
$"{health:#}\t{hueInfo.Name:#}\t{typeInfo.Name:#}"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (_showType)
|
|
{
|
|
var plantNumber = _plantStatus == PlantStatus.Plant
|
|
? typeInfo.GetPlantLabelPlant(hueInfo)
|
|
: typeInfo.GetPlantLabelSeed(hueInfo);
|
|
|
|
if (list != _oldClientPropertyList)
|
|
{
|
|
list.Add(
|
|
plantNumber,
|
|
$"{container:#}\t{dirt:#}\t{health:#}\t{hueInfo.Name:#}\t{typeInfo.Name:#}\t{plantStatus:#}"
|
|
);
|
|
}
|
|
else
|
|
{
|
|
list.Add(
|
|
plantNumber,
|
|
$"{dirt:#}\t{health:#}\t{hueInfo.Name:#}\t{typeInfo.Name:#}\t{plantStatus:#}"
|
|
);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var category = typeInfo.PlantCategory == PlantCategory.Default ? hueInfo.Name : (int)typeInfo.PlantCategory;
|
|
var plantNumber = hueInfo.IsBright() ? 1060832 : 1060831;
|
|
if (list != _oldClientPropertyList)
|
|
{
|
|
list.Add(plantNumber, $"{container:#}\t{dirt:#}\t{health:#}\t{category:#}\t{plantStatus:#}");
|
|
}
|
|
else
|
|
{
|
|
list.Add(plantNumber, $"{dirt:#}\t{health:#}\t{category:#}\t{plantStatus:#}");
|
|
}
|
|
}
|
|
}
|
|
|
|
public bool IsUsableBy(Mobile from) =>
|
|
IsChildOf(from.Backpack) || IsChildOf(from.FindBankNoCreate()) || IsLockedDown && IsAccessibleTo(from) ||
|
|
RootParent is Item root && root.IsSecure && root.IsAccessibleTo(from);
|
|
|
|
public override void OnDoubleClick(Mobile from)
|
|
{
|
|
if (_plantStatus >= PlantStatus.DecorativePlant)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!IsChildOf(from))
|
|
{
|
|
var loc = GetWorldLocation();
|
|
|
|
if (!from.InLOS(loc) || !from.InRange(loc, 2))
|
|
{
|
|
from.LocalOverheadMessage(MessageType.Regular, 0x3E9, 1019045); // I can't reach that
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!IsUsableBy(from))
|
|
{
|
|
LabelTo(from, 1061856); // You must have the item in your backpack or locked down in order to use it.
|
|
return;
|
|
}
|
|
|
|
MainPlantGump.DisplayTo(from, this);
|
|
}
|
|
|
|
public void PlantSeed(Mobile from, Seed seed)
|
|
{
|
|
if (_plantStatus >= PlantStatus.FullGrownPlant)
|
|
{
|
|
LabelTo(from, 1061919); // You must use a seed on some prepared soil!
|
|
}
|
|
else if (!IsUsableBy(from))
|
|
{
|
|
LabelTo(from, 1061921); // The bowl of dirt must be in your pack, or you must lock it down.
|
|
}
|
|
else if (_plantStatus != PlantStatus.BowlOfDirt)
|
|
{
|
|
// This bowl of dirt already has a ~1_val~ in it!
|
|
from.SendLocalizedMessage(1080389, $"#{GetLocalizedPlantStatus()}");
|
|
}
|
|
else if (PlantSystem.Water < 2)
|
|
{
|
|
LabelTo(from, 1061920); // The dirt needs to be softened first.
|
|
}
|
|
else
|
|
{
|
|
PlantType = seed.PlantType;
|
|
PlantHue = seed.PlantHue;
|
|
ShowType = seed.ShowType;
|
|
|
|
seed.Consume();
|
|
|
|
PlantStatus = PlantStatus.Seed;
|
|
|
|
PlantSystem.Reset(false);
|
|
|
|
LabelTo(from, 1061922); // You plant the seed in the bowl of dirt.
|
|
}
|
|
}
|
|
|
|
public void Die()
|
|
{
|
|
if (_plantStatus >= PlantStatus.FullGrownPlant)
|
|
{
|
|
PlantStatus = PlantStatus.DeadTwigs;
|
|
}
|
|
else
|
|
{
|
|
PlantStatus = PlantStatus.BowlOfDirt;
|
|
PlantSystem.Reset(true);
|
|
}
|
|
}
|
|
|
|
public void Pour(Mobile from, Item item)
|
|
{
|
|
if (_plantStatus >= PlantStatus.DeadTwigs)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_plantStatus == PlantStatus.DecorativePlant)
|
|
{
|
|
LabelTo(from, 1053049); // This is a decorative plant, it does not need watering!
|
|
return;
|
|
}
|
|
|
|
if (!IsUsableBy(from))
|
|
{
|
|
LabelTo(from, 1061856); // You must have the item in your backpack or locked down in order to use it.
|
|
return;
|
|
}
|
|
|
|
if (item is BaseBeverage beverage)
|
|
{
|
|
if (beverage.IsEmpty || !beverage.Pourable || beverage.Content != BeverageType.Water)
|
|
{
|
|
LabelTo(from, 1053069); // You can't use that on a plant!
|
|
return;
|
|
}
|
|
|
|
if (!beverage.ValidateUse(from, true))
|
|
{
|
|
return;
|
|
}
|
|
|
|
beverage.Quantity--;
|
|
PlantSystem.Water++;
|
|
|
|
from.PlaySound(0x4E);
|
|
LabelTo(from, 1061858); // You soften the dirt with water.
|
|
}
|
|
else if (item is BasePotion potion)
|
|
{
|
|
if (ApplyPotion(potion.PotionEffect, false, out var message))
|
|
{
|
|
potion.Consume();
|
|
from.PlaySound(0x240);
|
|
from.AddToBackpack(new Bottle());
|
|
}
|
|
|
|
LabelTo(from, message);
|
|
}
|
|
else if (item is PotionKeg keg)
|
|
{
|
|
if (keg.Held <= 0)
|
|
{
|
|
LabelTo(from, 1053069); // You can't use that on a plant!
|
|
return;
|
|
}
|
|
|
|
if (ApplyPotion(keg.Type, false, out var message))
|
|
{
|
|
keg.Held--;
|
|
from.PlaySound(0x240);
|
|
}
|
|
|
|
LabelTo(from, message);
|
|
}
|
|
else
|
|
{
|
|
LabelTo(from, 1053069); // You can't use that on a plant!
|
|
}
|
|
}
|
|
|
|
public bool ApplyPotion(PotionEffect effect, bool testOnly, out int message)
|
|
{
|
|
if (_plantStatus >= PlantStatus.DecorativePlant)
|
|
{
|
|
message = 1053049; // This is a decorative plant, it does not need watering!
|
|
return false;
|
|
}
|
|
|
|
if (_plantStatus == PlantStatus.BowlOfDirt)
|
|
{
|
|
message = 1053066; // You should only pour potions on a plant or seed!
|
|
return false;
|
|
}
|
|
|
|
var full = false;
|
|
|
|
if (effect is PotionEffect.PoisonGreater or PotionEffect.PoisonDeadly)
|
|
{
|
|
if (PlantSystem.IsFullPoisonPotion)
|
|
{
|
|
full = true;
|
|
}
|
|
else if (!testOnly)
|
|
{
|
|
PlantSystem.PoisonPotion++;
|
|
}
|
|
}
|
|
else if (effect == PotionEffect.CureGreater)
|
|
{
|
|
if (PlantSystem.IsFullCurePotion)
|
|
{
|
|
full = true;
|
|
}
|
|
else if (!testOnly)
|
|
{
|
|
PlantSystem.CurePotion++;
|
|
}
|
|
}
|
|
else if (effect == PotionEffect.HealGreater)
|
|
{
|
|
if (PlantSystem.IsFullHealPotion)
|
|
{
|
|
full = true;
|
|
}
|
|
else if (!testOnly)
|
|
{
|
|
PlantSystem.HealPotion++;
|
|
}
|
|
}
|
|
else if (effect == PotionEffect.StrengthGreater)
|
|
{
|
|
if (PlantSystem.IsFullStrengthPotion)
|
|
{
|
|
full = true;
|
|
}
|
|
else if (!testOnly)
|
|
{
|
|
PlantSystem.StrengthPotion++;
|
|
}
|
|
}
|
|
else if (effect is PotionEffect.PoisonLesser or PotionEffect.Poison or PotionEffect.CureLesser or PotionEffect.Cure or PotionEffect.HealLesser or PotionEffect.Heal or PotionEffect.Strength)
|
|
{
|
|
message = 1053068; // This potion is not powerful enough to use on a plant!
|
|
return false;
|
|
}
|
|
else
|
|
{
|
|
message = 1053069; // You can't use that on a plant!
|
|
return false;
|
|
}
|
|
|
|
if (full)
|
|
{
|
|
message = 1053065; // The plant is already soaked with this type of potion!
|
|
return false;
|
|
}
|
|
|
|
message = 1053067; // You pour the potion over the plant.
|
|
return true;
|
|
}
|
|
|
|
private void Deserialize(IGenericReader reader, int version)
|
|
{
|
|
_level = (SecureLevel)reader.ReadInt();
|
|
_plantStatus = (PlantStatus)reader.ReadInt();
|
|
_plantType = (PlantType)reader.ReadInt();
|
|
_plantHue = (PlantHue)reader.ReadInt();
|
|
_showType = reader.ReadBool();
|
|
|
|
if (_plantStatus < PlantStatus.DecorativePlant)
|
|
{
|
|
_plantSystem = new PlantSystem(this);
|
|
_plantSystem.Deserialize(reader);
|
|
}
|
|
}
|
|
|
|
[AfterDeserialization]
|
|
private void AfterDeserialization()
|
|
{
|
|
Plants.Add(this);
|
|
}
|
|
|
|
public override void OnAfterDelete()
|
|
{
|
|
base.OnAfterDelete();
|
|
|
|
Plants.Remove(this);
|
|
}
|
|
}
|