ModernUO/Projects/UOContent/Items/Skill Items/Magical/Potions/BasePotion.cs
Kamron Batman 992bc95164
feat: Refactor Poison system, implement Darkglow & Parasitic effects (#2385)
## Summary

- Refactors the poison system to separate `Index` (globally unique ID) from `Level` (tier within a family), enabling multiple poison families (Standard, Darkglow, Parasitic) to coexist without collisions
- Implements Darkglow and Parasitic poison special effects from Mondain's Legacy: Darkglow boosts damage by 10% when attacker is ranged, Parasitic heals the attacker for damage dealt in melee range
- Fixes several bugs: `Register()` crashing on duplicate `Level` values across families, `IncreaseLevel()` crossing family boundaries, `InfectiousStrike` and `NinjaWeapons` stripping poison family via level-based lookups, and `ArchCure`/`CleansingWinds` using raw `Level + 1` instead of `IncreaseLevel()`

## Changes

**`Projects/Server/Poison.cs`** — Adds `PoisonFamily` enum and abstract `Family` property. Adds `Index` as unique identifier. Fixes `Register()` to check `Index` uniqueness (not `Level`) and validate the new poison's name (not the existing one's). Fixes `IncreaseLevel()` to use `Index + 1`, naturally respecting family boundaries via Index gaps. Replaces linear name lookup with `Dictionary`-based `PoisonsByName`.

**`Projects/UOContent/Misc/Poison.cs`** — Adds `family` parameter to `PoisonImpl`. Implements Darkglow effect (10% damage boost when `From` >1 tile, cliloc 1072850) and Parasitic effect (heals `From` for damage dealt within 1 tile, cliloc 1060203) in `PoisonTimer.OnTick()`. Renames `m_` fields to `_` convention.

**`Projects/UOContent/Misc/PoisonKinds.cs`** — New file. Moves poison registration out of `PoisonImpl` into `PoisonKinds.Configure()`. Adds `PoisonFamily` to Darkglow/Parasitic registrations. Provides extension properties (`Lesser`, `Deadly`, `LesserDarkglow`, etc.), `GetPoison(int level)` (standard-only), `GetPoisonByFamilyAndLevel()`, and `IsDarkglow`/`IsParasitic` instance helpers.

**`Projects/UOContent/Items/Weapons/Abilities/InfectiousStrike.cs`** — Family-aware poison scaling: Darkglow caps at Deadly (Poisoning/33.3), Parasitic caps at Lethal (Poisoning/25), Standard unchanged. Level bump uses `IncreaseLevel()` with family boundary check.

**`Projects/UOContent/Items/Skill Items/Ninjitsu/NinjaWeapons.cs`** — EvilOmen level bump uses `Poison.IncreaseLevel()` instead of `Poison.GetPoison(Level + 1)`.

**`Projects/UOContent/Spells/Fourth/ArchCure.cs`** and **`CleansingWindsSpell.cs`** — Replace `poison.Level + 1` with `Poison.IncreaseLevel(poison).Level` for family-safe cure chance calculation.

**`Projects/Server/Serialization/SerializationExtensions.cs`** — Serializes/deserializes `Index` instead of `Level`.

**`DarkglowPotion.cs`** / **`ParasiticPotion.cs`** — Point to actual Darkglow/Parasitic poisons instead of placeholder `Greater`.

**`PotionKeg.cs`** / **`BasePotion.cs`** — Adds Darkglow, Parasitic, Invisibility, and FlintsPungentBrew to `PotionEffect` enum and keg label support.

## Test plan

- [ ] `dotnet build` compiles cleanly (verified, 0 warnings 0 errors)
- [ ] Verify `PoisonKinds.Configure()` registers all poisons without throwing (Register bug fix)
- [ ] Standard poison behavior unchanged — PoisonField, PoisonSpell, SerpentArrow, SavageShaman, TrappableContainer all use `GetPoison(int level)` which now correctly filters to Standard family
- [ ] Darkglow: poison tick deals +10% damage when attacker is >1 tile away, sends "Darkglow poison increases your damage!" message
- [ ] Parasitic: poison tick heals attacker for damage dealt when within 1 tile, sends heal message
- [ ] InfectiousStrike preserves poison family and respects family-specific skill scaling
- [ ] EvilOmen + NinjaWeapons level bump stays within poison family
- [ ] ArchCure/CleansingWinds cure chance calculations work correctly across all poison families
- [ ] Serialization round-trips correctly using Index
2026-03-21 21:27:22 -07:00

247 lines
5.6 KiB
C#

using System;
using ModernUO.Serialization;
using Server.Engines.ConPVP;
using Server.Engines.Craft;
namespace Server.Items;
public enum PotionEffect
{
Nightsight,
CureLesser,
Cure,
CureGreater,
Agility,
AgilityGreater,
Strength,
StrengthGreater,
PoisonLesser,
Poison,
PoisonGreater,
PoisonDeadly,
Refresh,
RefreshTotal,
HealLesser,
Heal,
HealGreater,
ExplosionLesser,
Explosion,
ExplosionGreater,
Conflagration,
ConflagrationGreater,
MaskOfDeath, // Mask of Death is not available in OSI but does exist in cliloc files
MaskOfDeathGreater, // included in enumeration for compatibility if later enabled by OSI
ConfusionBlast,
ConfusionBlastGreater,
Invisibility,
Parasitic,
Darkglow,
FlintsPungentBrew
}
[SerializationGenerator(2, false)]
public abstract partial class BasePotion : Item, ICraftable, ICommodity
{
[InvalidateProperties]
[SerializableField(0)]
private PotionEffect _potionEffect;
public BasePotion(int itemID, PotionEffect effect) : base(itemID)
{
_potionEffect = effect;
Stackable = Core.ML;
}
public override double DefaultWeight => 1.0;
public override int LabelNumber => 1041314 + (int)_potionEffect;
public virtual bool RequireFreeHand => true;
public virtual bool IsThrowablePotion => false;
int ICommodity.DescriptionNumber => LabelNumber;
bool ICommodity.IsDeedable => Core.ML;
public int OnCraft(
int quality,
bool makersMark,
Mobile from,
CraftSystem craftSystem,
Type typeRes,
BaseTool tool,
CraftItem craftItem,
int resHue
)
{
if (craftSystem is DefAlchemy)
{
var pack = from.Backpack;
if (pack != null)
{
if ((int)PotionEffect >= (int)PotionEffect.Invisibility)
{
return 1;
}
using var queue = pack.EnumerateItemsByType<PotionKeg>();
foreach (var keg in queue)
{
if (keg.Held is <= 0 or >= 100)
{
continue;
}
if (keg.Type != PotionEffect)
{
continue;
}
++keg.Held;
Consume();
from.AddToBackpack(new Bottle());
return -1; // signal placed in keg
}
}
}
return 1;
}
public static bool HasFreeHand(Mobile m)
{
var handOne = m.FindItemOnLayer(Layer.OneHanded);
var handTwo = m.FindItemOnLayer(Layer.TwoHanded);
if (handTwo is BaseWeapon)
{
handOne = handTwo;
}
if (handTwo is BaseRanged ranged && ranged.Balanced)
{
return true;
}
return handOne == null || handTwo == null;
}
public override void OnDoubleClick(Mobile from)
{
if (!CanDrink(from))
{
return;
}
var pot = this;
if (IsThrowablePotion && Amount > 1)
{
pot = GetType().CreateInstance<BasePotion>();
Amount--;
if (from.Backpack?.Deleted == false)
{
from.Backpack.DropItem(pot);
}
else
{
pot.MoveToWorld(from.Location, from.Map);
}
}
pot.Drink(from);
}
private void Deserialize(IGenericReader reader, int version)
{
_potionEffect = (PotionEffect)reader.ReadInt();
}
public virtual bool CanDrink(Mobile from)
{
if (!Movable)
{
return false;
}
if (!from.InRange(GetWorldLocation(), 1))
{
from.SendLocalizedMessage(502138); // That is too far away for you to use
return false;
}
if (RequireFreeHand && !HasFreeHand(from))
{
from.SendLocalizedMessage(502172); // You must have a free hand to drink a potion.
return false;
}
return true;
}
public abstract void Drink(Mobile from);
public void PlayDrinkEffect(Mobile m)
{
m.RevealingAction();
m.PlaySound(0x2D6);
if (!DuelContext.IsFreeConsume(m))
{
Consume();
m.AddToBackpack(new Bottle());
}
if (m.Body.IsHuman && !m.Mounted)
{
m.Animate(34, 5, 1, true, false, 0);
}
}
public static int EnhancePotions(Mobile m)
{
var EP = AosAttributes.GetValue(m, AosAttribute.EnhancePotions);
var skillBonus = (int)(m.Skills.Alchemy.Value * 10 / 33);
if (Core.ML && EP > 50 && m.AccessLevel <= AccessLevel.Player)
{
EP = 50;
}
return EP + skillBonus;
}
public static TimeSpan Scale(Mobile m, TimeSpan v)
{
if (!Core.AOS)
{
return v;
}
return v * (1.0 + 0.01 * EnhancePotions(m));
}
public static double Scale(Mobile m, double v)
{
if (!Core.AOS)
{
return v;
}
var scalar = 1.0 + 0.01 * EnhancePotions(m);
return v * scalar;
}
public static int Scale(Mobile m, int v) => !Core.AOS ? v : AOS.Scale(v, 100 + EnhancePotions(m));
public override bool StackWith(Mobile from, Item dropped, bool playSound) =>
dropped is BasePotion potion && potion._potionEffect == _potionEffect &&
base.StackWith(from, potion, playSound);
}