ModernUO/Projects/UOContent/Misc/Poison.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

162 lines
5.2 KiB
C#

using System;
using Server.Engines.Virtues;
using Server.Items;
using Server.Mobiles;
using Server.Spells;
using Server.Spells.Necromancy;
using Server.Spells.Ninjitsu;
namespace Server;
public class PoisonImpl : Poison
{
private readonly int _count;
private readonly TimeSpan _delay;
private readonly TimeSpan _interval;
private readonly int _maximum;
private readonly int _messageInterval;
private readonly int _minimum;
private readonly double _scalar;
public PoisonImpl(
string name, int index, int level, int min, int max, double percent, double delay, double interval, int count,
int messageInterval, PoisonFamily family = PoisonFamily.Standard
) : base(index)
{
Name = name;
Level = level;
Family = family;
_minimum = min;
_maximum = max;
_scalar = percent * 0.01;
_delay = TimeSpan.FromSeconds(delay);
_interval = TimeSpan.FromSeconds(interval);
_count = count;
_messageInterval = messageInterval;
}
public override string Name { get; }
public override int Level { get; }
public override PoisonFamily Family { get; }
public override Timer ConstructTimer(Mobile m) => new PoisonTimer(m, this);
public class PoisonTimer : Timer
{
private readonly Mobile _mobile;
private readonly PoisonImpl _poison;
private int _index;
private int _lastDamage;
public PoisonTimer(Mobile m, PoisonImpl p) : base(p._delay, p._interval)
{
From = m;
_mobile = m;
_poison = p;
}
public Mobile From{ get; set; }
protected override void OnTick()
{
if ((Core.AOS && _poison.Level < 4 &&
TransformationSpellHelper.UnderTransformation(_mobile, typeof(VampiricEmbraceSpell)) ||
_poison.Level < 3 && OrangePetals.UnderEffect(_mobile) ||
AnimalForm.UnderTransformation(_mobile, typeof(Unicorn))) && _mobile.CurePoison(_mobile))
{
if (Core.SA)
{
// * You feel yourself resisting the effects of the poison *
_mobile.LocalOverheadMessage(MessageType.Emote, 0x3F, 1114441);
}
else
{
_mobile.LocalOverheadMessage(
MessageType.Emote,
0x3F,
true,
"* You feel yourself resisting the effects of the poison *"
);
}
if (Core.SA)
{
// * ~1_NAME~ seems resistant to the poison *
_mobile.NonlocalOverheadMessage(MessageType.Emote, 0x3F, 1114442, _mobile.Name);
}
else
{
_mobile.LocalOverheadMessage(
MessageType.Emote,
0x3F,
true,
$"* {_mobile.Name} seems resistant to the poison *"
);
}
Stop();
return;
}
if (_index++ == _poison._count)
{
_mobile.SendLocalizedMessage(502136); // The poison seems to have worn off.
_mobile.Poison = null;
Stop();
return;
}
int damage;
if (!Core.AOS && _lastDamage != 0 && Utility.RandomBool())
{
damage = _lastDamage;
}
else
{
damage = 1 + (int)(_mobile.Hits * _poison._scalar);
damage = Math.Clamp(damage, _poison._minimum, _poison._maximum);
_lastDamage = damage;
}
// Darkglow: 10% damage boost when attacker is more than 1 tile away
if (_poison.Family == PoisonFamily.Darkglow && From != null && From.Map == _mobile.Map &&
!From.InRange(_mobile, 1))
{
damage = (int)(damage * 1.1);
// Darkglow poison increases your damage!
From.SendLocalizedMessage(1072850);
}
From?.DoHarmful(_mobile, true);
(_mobile as IHonorTarget)?.ReceivedHonorContext?.OnTargetPoisoned();
AOS.Damage(_mobile, From, damage, 0, 0, 0, 100, 0);
// Parasitic: heals attacker for damage dealt when within 1 tile
if (_poison.Family == PoisonFamily.Parasitic && From != null && From.Map == _mobile.Map &&
From.InRange(_mobile, 1))
{
From.Heal(damage);
// You have had ~1_HEALED_AMOUNT~ hit points healed.
From.SendLocalizedMessage(1060203, damage.ToString());
}
// OSI: randomly revealed between first and third damage tick, guessing 60% chance
if (Utility.RandomDouble() < 0.40)
{
_mobile.RevealingAction();
}
if (_index % _poison._messageInterval == 0)
{
_mobile.OnPoisoned(From, _poison, _poison);
}
}
}
}