A cursed item¶
Wearing it applies a status effect. Taking it off removes it — and only the one the ring put there, not the identical effect your potion granted a moment ago.
Recipe
Systems required: Inventory, StatusEffects. Package: Complete only — the systems above ship in different packages, so no single-system package can run this. Shape: one class you drop into a project that already exists. No scene, no prefab, no setup ritual. Public API only. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.
The curse lives on the item¶
Any item whose tags carry curse:poison is cursed. A designer makes a new cursed item by typing a tag on the asset — there is no list inside the component to keep in step with the item database, and nothing to forget when a new item ships.
Type the id in the case it was registered — every shipped one is lowercase
StatusId compares ordinally, so the tag has to name the id exactly. Every id RevFramework registers is lowercase: curse:poison builds, and a capitalised spelling of it silently builds nothing. The Status Effects public API lists all nine.
The recipe reads def.tags rather than ItemDefinition.NormalizedTags for a related reason running the other way: normalisation lowercases, which would quietly break a project that registers its own ids in mixed case. Reading the raw tags keeps whatever case the author typed, and the cost is that the author has to type it correctly. It is a good illustration of what to watch for whenever a string crosses from one system into another: each end normalises for its own purposes, and the two conventions do not have to agree.
The part that is not obvious¶
A swap raises no unequip. Equipping into an occupied slot returns the old item to the bag and raises OnEquipped for the incoming one — nothing is raised for the item that left. So the obvious implementation, apply on OnEquipped and remove on OnUnequipped, leaks the old curse permanently every time the player swaps rings.
The fix is a change of shape rather than an extra event handler: reconcile, don't react. Every event just calls Resync(), which compares what is worn against what is applied and fixes the difference. That makes the component idempotent, swap-proof, and safe to call by hand.
Call Resync() after loading a save
Restoring equipment does not raise equip events either, so nothing else will. One call after your restore completes and the worn curses are back.
Removal has to be by application, not by id. RemoveStatus(id) removes every effect sharing that id, so taking off a cursed ring would also strip the identical effect that came from a potion. Each application here carries a StatusContext naming this component and the slot, and removal walks Active looking for the context it wrote. It also checks Instigator, because two wearers in the same scene use the same slot names.
That is also the reason the component takes the concrete StatusEffectController rather than IStatusEffectController: the interface exposes only by-id removal, so precise removal is not expressible through it.
'Until removed' is not zero, and not infinity
A duration of zero is already expired — the curse would evaporate on the next tick. Infinity never expires, but it makes TimeRemaining / Duration a NaN, and that division is what every duration readout computes, including IStatusEffect.NormalizedRemaining and the radial fill on StatusIconView. A very large finite duration never expires either and leaves those readouts sitting at full, which is why the recipe uses one.
Drop it in¶
using System;
using System.Collections.Generic;
using RevGaming.RevFramework.Inventory.Data;
using RevGaming.RevFramework.Inventory.Equipment;
using RevGaming.RevFramework.StatusEffects.Abstractions;
using RevGaming.RevFramework.StatusEffects.Core;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.CursedEquipment
{
/// <summary>
/// A cursed item: wearing it applies a status effect, taking it off removes it — and only the one
/// this component applied.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Inventory</b>, <b>StatusEffects</b>. Public API only.</para>
///
/// <para><b>The curse is authored on the item, not here.</b> Any <see cref="ItemDefinition"/> whose
/// <c>tags</c> carry <c>curse:someStatusId</c> is cursed, so a designer makes a new cursed item by
/// typing a tag on the asset — there is no list in this component to keep in step with the item
/// database.</para>
///
/// <para><b>It reconciles rather than reacts,</b> which is the part worth copying even if you never
/// ship a cursed ring. Equipping into an occupied slot swaps: <see cref="CharacterEquipment"/>
/// raises <c>OnEquipped</c> for the incoming item, and raises nothing for the one that was swapped
/// out and returned to the bag. A handler that removes on <c>OnUnequipped</c> and applies on
/// <c>OnEquipped</c> therefore leaks the old curse, permanently, on every swap. So every event just
/// calls <see cref="Resync"/>, which compares what is worn against what is applied and fixes the
/// difference. The reconcile points are those two events, <c>OnEnable</c>, and the calls you make
/// yourself — and there are more things that need one than you would guess, so read
/// <see cref="Resync"/> before shipping this.</para>
///
/// <para><b>Removal is by application, not by id.</b> <c>RemoveStatus(id)</c> removes every effect
/// sharing that id, so taking off a cursed ring would also strip the identical effect a potion had
/// granted. Each application here carries a <see cref="StatusContext"/> naming this character and
/// the slot, and removal walks <see cref="StatusEffectController.Active"/> for the one whose
/// context matches. That is also why this takes the concrete
/// <see cref="StatusEffectController"/> rather than <see cref="IStatusEffectController"/>: the
/// interface exposes only the by-id removal, so precise removal is not expressible through it.</para>
///
/// <para><b>Precise removal protects other sources on the way out, not on the way in.</b> The
/// effect's own <see cref="IStatusEffect.Stacking"/> rule still governs the apply, and under
/// <c>Replace</c> — which every id RevFramework ships uses except <c>slow</c> and <c>haste</c> —
/// applying the curse removes whatever effect of that id was already running, whoever put it there.
/// So the potion's poison survives the cursed ring coming <i>off</i>, but not the ring going
/// <i>on</i>. Only a <c>Stack</c> effect gives you the coexistence the context matching implies.</para>
///
/// <para><b>"Until removed" is spelled <see cref="EffectivelyPermanent"/>,</b> not zero and not
/// infinity. A duration of zero is <i>already expired</i>, so the curse would evaporate on the next
/// tick. Infinity never expires, but it makes <c>TimeRemaining / Duration</c> a NaN, which is what
/// every duration readout in the framework and probably in your HUD computes — including
/// <c>IStatusEffect.NormalizedRemaining</c> and the radial fill on <c>StatusIconView</c>. A very
/// large finite duration never expires either, and leaves those readouts sitting at full.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class CursedEquipment : MonoBehaviour
{
/// <summary>
/// Duration that means "until this component removes it", chosen to keep timer maths finite.
/// </summary>
public const float EffectivelyPermanent = float.MaxValue;
[Tooltip("The equipment being watched. Defaults to one on this GameObject.")]
[SerializeField] private CharacterEquipment equipment;
[Tooltip("Who the curse is applied to. Defaults to one on this GameObject.")]
[SerializeField] private StatusEffectController status;
[Tooltip("Item tag prefix that marks a curse. \"curse:poison\" applies the poison status. Ids " +
"are compared ordinally and every id RevFramework ships is lowercase, so " +
"a capitalised id curses nothing.")]
[SerializeField] private string curseTagPrefix = "curse:";
[Tooltip("Magnitude passed to the status builder — how strong the curse is. What it means is " +
"the effect's business: poison and burn read it as damage per second, while " +
"vulnerability, slow and haste all treat 1 as \"no change\".")]
[SerializeField, Min(0f)] private float magnitude = 1f;
/// <summary>Reconcile passes allowed per call before giving up, so a feedback loop terminates.</summary>
private const int MaxReconcilePasses = 4;
private readonly Dictionary<string, string> _wanted = new();
private readonly List<string> _unknownIdsWarned = new();
private readonly List<string> _refusedIdsWarned = new();
private bool _reconciling;
private bool _reconcileAgain;
private void Reset()
{
equipment = GetComponent<CharacterEquipment>();
status = GetComponent<StatusEffectController>();
}
private void OnEnable()
{
if (!equipment) equipment = GetComponent<CharacterEquipment>();
if (!status) status = GetComponent<StatusEffectController>();
if (!equipment || !status)
{
Debug.LogWarning($"[{nameof(CursedEquipment)}] Needs a {nameof(CharacterEquipment)} and a " +
$"{nameof(StatusEffectController)}. Disabling.", this);
enabled = false;
return;
}
equipment.OnEquipped += OnSlotChanged;
equipment.OnUnequipped += OnSlotChanged;
// Whatever is already worn was equipped before this component woke up — on a restored save,
// or on a prefab authored with a slot filled. Reconciling here is what makes those cases
// behave the same as an equip that happened while watching.
Resync();
}
private void OnDisable()
{
if (equipment)
{
equipment.OnEquipped -= OnSlotChanged;
equipment.OnUnequipped -= OnSlotChanged;
}
// Curses are left in place deliberately: this component going away is not the wearer taking
// the ring off. That choice only bites when this component alone is disabled — deactivating
// the whole character runs StatusEffectController.OnDisable, which tears every effect down
// whatever this one decided, and OnEnable's Resync is what puts them back afterwards. To
// strip them without taking the items off, call CleanseCurses().
}
private void OnSlotChanged(string slotId, ItemStack stack) => Resync();
/// <summary>
/// Brings applied curses into line with what is currently worn.
/// </summary>
/// <remarks>
/// <para>Reaches a fixed point: a second call in the same state does nothing. It is not quite
/// one effect per slot, though — two slots inflicting the <i>same</i> id whose
/// <see cref="IStatusEffect.Stacking"/> is not <c>Stack</c> collapse to a single effect, because
/// the controller allows only one. This keeps that one alive while either slot still wants it.
/// Every id RevFramework ships is in that group except <c>slow</c> and <c>haste</c>.</para>
///
/// <para><b>Call it yourself whenever something changed what is worn, or removed a curse,
/// without raising an equip event.</b> The silent paths are not rare:
/// restoring a save restores equipment with no events (hook <c>RevSaveManager.LoadCompleted</c>,
/// which is documented for exactly this); <c>CharacterEquipment.SetLayout</c> empties every slot
/// and raises nothing; a dispel, a cleanse or <c>ClearAll</c> takes the curse off while the ring
/// is still worn; a potion or an in-box <c>StatusAuraZone</c> applying the same id destroys the
/// curse under <c>Replace</c>; and disabling the controller tears everything down. Until the
/// next reconcile the item is worn and uncursed, silently.</para>
/// </remarks>
public void Resync()
{
if (!equipment || !status)
return;
// Re-entered: an effect's own Apply/Remove, or a StatusApplied listener, changed equipment
// while a reconcile was in flight. Reconciling now would run against half-applied state and
// rewrite the collection the pass in flight is walking, so ask for another pass instead.
if (_reconciling)
{
_reconcileAgain = true;
return;
}
_reconciling = true;
try
{
for (int pass = 0; pass < MaxReconcilePasses; pass++)
{
_reconcileAgain = false;
CollectWanted();
RemoveStaleCurses();
ApplyMissingCurses();
if (!_reconcileAgain)
return;
}
Debug.LogWarning($"[{nameof(CursedEquipment)}] Gave up after {MaxReconcilePasses} passes: " +
"something equips or unequips in response to a curse being applied. " +
"The applied curses may not match what is worn.", this);
}
finally
{
_reconciling = false;
}
}
/// <summary>
/// Removes every curse this component applied, and leaves every other effect alone.
/// </summary>
/// <remarks>
/// The counterpart to <see cref="Resync"/> for stripping curses without taking the items off —
/// cleansing on disable, say. The next <see cref="Resync"/> re-applies whatever is still worn.
/// Does nothing if called from inside a reconcile, since that pass owns the state.
/// </remarks>
public void CleanseCurses()
{
if (!status || _reconciling)
return;
_reconciling = true;
try
{
_wanted.Clear();
RemoveStaleCurses();
}
finally
{
_reconciling = false;
}
}
/// <summary>
/// Reads the worn items and records the status each slot should be inflicting.
/// </summary>
private void CollectWanted()
{
_wanted.Clear();
List<CharacterEquipment.SlotConfig> layout = equipment.layout;
if (layout == null)
return;
for (int i = 0; i < layout.Count; i++)
{
string slotId = layout[i].slotId;
if (string.IsNullOrWhiteSpace(slotId))
continue;
ItemStack worn = equipment.GetEquipped(slotId);
if (worn.IsEmpty || !worn.def)
continue;
string curse = CurseIdOf(worn.def);
if (curse != null)
_wanted[SlotKeys.Normalize(slotId)] = curse;
}
}
/// <summary>
/// Removes curses this component applied that the worn items no longer justify.
/// </summary>
private void RemoveStaleCurses()
{
IReadOnlyList<IStatusEffect> active = status.Active;
// Backwards, because removal shortens the list this is walking.
for (int i = active.Count - 1; i >= 0; i--)
{
// Active is the controller's live list, and a removal raises listeners that can remove
// more, so the index may no longer be in range on a later pass. The framework's own
// removal loop carries this same re-check for the same reason; copy it.
if (i >= active.Count)
continue;
IStatusEffect effect = active[i];
if (effect == null)
continue;
string slot = SlotOfOurContext(status.GetContext(effect));
if (slot == null)
continue;
string id = (string)effect.Id;
bool stillWanted = _wanted.TryGetValue(slot, out string wantedId) && wantedId == id;
// A non-Stack effect exists once per id however many slots asked for it, so the single
// survivor stays tagged with the slot that applied it first. Removing it because that
// slot emptied would strip a curse another worn slot is still paying for.
if (!stillWanted && effect.Stacking != StatusStackingRule.Stack)
stillWanted = AnyWornSlotWants(id);
if (!stillWanted)
status.RemoveStatusAt(i);
}
}
/// <summary>
/// Applies the curses that should be running and are not.
/// </summary>
private void ApplyMissingCurses()
{
foreach (KeyValuePair<string, string> pair in _wanted)
{
if (IsOurCurseActive(pair.Key, pair.Value))
continue;
var id = new StatusId(pair.Value);
// Duration first, then magnitude. Both are floats, so the compiler cannot catch the
// swap and a curse with a magnitude-long duration looks like a curse that vanishes.
if (!StatusRegistry.TryBuild(id, EffectivelyPermanent, magnitude, out IStatusEffect effect))
{
WarnOnce(_unknownIdsWarned, pair.Value,
$"No status registered as '{pair.Value}'. The tag " +
$"'{curseTagPrefix}{pair.Value}' will not curse anything — check spelling " +
"and case.");
continue;
}
// Two rings inflicting the same non-Stack status do not make two effects. Applying the
// second tears the first down and puts an identical one back — on every reconcile, for
// ever. One effect; RemoveStaleCurses keeps it while either slot wants it.
if (effect.Stacking != StatusStackingRule.Stack && IsAnyOfOurCursesActive(pair.Value))
continue;
status.ApplyStatus(effect, new StatusContext(
instigator: gameObject,
sourceDef: null,
sourceId: SourceIdFor(pair.Key),
sourceSlot: 0,
note: "cursed equipment"));
// ApplyStatus returns void and does nothing at all when authority is denied or an
// IStatusImmunity blocks the id. Assuming it worked is how a reconcile ends up building
// and discarding an effect on every equip for ever, and never saying so — check.
if (!IsOurCurseActive(pair.Key, pair.Value))
WarnOnce(_refusedIdsWarned, pair.Value,
$"The controller refused the '{pair.Value}' curse: authority is denied, or " +
"an IStatusImmunity blocks that id. The item is worn and uncursed.");
}
}
/// <summary>
/// Whether the curse this component would apply for a slot is already running.
/// </summary>
private bool IsOurCurseActive(string slot, string statusId)
{
IReadOnlyList<IStatusEffect> active = status.Active;
for (int i = 0; i < active.Count; i++)
{
IStatusEffect effect = active[i];
if (effect == null)
continue;
if (SlotOfOurContext(status.GetContext(effect)) == slot && (string)effect.Id == statusId)
return true;
}
return false;
}
/// <summary>
/// Whether this component has a curse of the given id running for any slot.
/// </summary>
private bool IsAnyOfOurCursesActive(string statusId)
{
IReadOnlyList<IStatusEffect> active = status.Active;
for (int i = 0; i < active.Count; i++)
{
IStatusEffect effect = active[i];
if (effect == null)
continue;
if ((string)effect.Id == statusId && SlotOfOurContext(status.GetContext(effect)) != null)
return true;
}
return false;
}
/// <summary>Whether any worn slot still asks for the given status id.</summary>
private bool AnyWornSlotWants(string statusId)
{
foreach (KeyValuePair<string, string> pair in _wanted)
{
if (pair.Value == statusId)
return true;
}
return false;
}
/// <summary>
/// The status id an item inflicts while worn, or null when it is not cursed.
/// </summary>
/// <remarks>
/// Reads the authored <see cref="ItemDefinition.tags"/> rather than
/// <see cref="ItemDefinition.NormalizedTags"/>, and the difference is not cosmetic:
/// normalisation lowercases, while <see cref="StatusId"/> compares ordinally. Reading the raw
/// tags preserves whatever case the tag was authored in, so a project that registers its own
/// ids in mixed case still works — normalising would silently break those.
///
/// <para>The cost of that choice is that the tag must name the id <i>exactly</i> as it was
/// registered. <b>Every id RevFramework ships is lowercase</b>, so <c>curse:vulnerability</c>
/// builds and a capitalised spelling of it does not. See the id table in
/// <c>Documentation/StatusEffects/PublicAPI</c>.</para>
/// </remarks>
private string CurseIdOf(ItemDefinition def)
{
string[] tags = def.tags;
if (tags == null || string.IsNullOrEmpty(curseTagPrefix))
return null;
for (int i = 0; i < tags.Length; i++)
{
string tag = tags[i];
// Ordinal, like everything else here. The single-argument overload compares by the
// current culture, so it can match where the prefix is not literally there — and the
// Substring below slices by the prefix's length regardless of what the match consumed.
if (string.IsNullOrWhiteSpace(tag) || !tag.StartsWith(curseTagPrefix, StringComparison.Ordinal))
continue;
string id = tag.Substring(curseTagPrefix.Length).Trim();
if (id.Length > 0)
return id;
}
return null;
}
/// <summary>The slot a context refers to, or null when this component did not write it.</summary>
private string SlotOfOurContext(StatusContext context)
{
string sourceId = context.SourceId;
if (string.IsNullOrEmpty(sourceId) || !sourceId.StartsWith(SourceIdPrefix, StringComparison.Ordinal))
return null;
// Instigator, not just the prefix. StatusContext has no per-component field, and
// SourceIdPrefix is a constant every instance shares, so two characters whose
// CursedEquipment components point at one shared controller would otherwise claim — and
// delete — each other's curses. Within a controller, the instigator is what tells them apart.
if (context.Instigator != gameObject)
return null;
return sourceId.Substring(SourceIdPrefix.Length);
}
private const string SourceIdPrefix = "recipe.cursedEquipment:";
private static string SourceIdFor(string slot) => SourceIdPrefix + slot;
/// <summary>
/// Warns once per id, because a reconcile that keeps failing would otherwise warn every event.
/// </summary>
private void WarnOnce(List<string> warned, string statusId, string message)
{
if (warned.Contains(statusId))
return;
warned.Add(statusId);
Debug.LogWarning($"[{nameof(CursedEquipment)}] {message}", this);
}
}
}
Wiring it up¶
- Put the component on the character that has the
CharacterEquipmentand theStatusEffectController. It finds both itself. - Tag a cursed item: add
curse:vulnerabilityto the item asset's Tags — lowercase, as registered. - If you load saves, call
Resync()once after your restore finishes.
Nothing else. Equip the item and the status appears; take it off and it goes.
Tuning¶
| Field | What it does |
|---|---|
curseTagPrefix | The tag prefix that marks a curse. curse: by default, so curse:poison inflicts poison. Ids are ordinal and every shipped one is lowercase. |
magnitude | Strength passed to the status builder. What it means depends on the effect. |
What it deliberately does not do¶
It does not cleanse on disable. A component being disabled is not the wearer taking the ring off, and a pooled or briefly-disabled character would otherwise lose effects it is still wearing the cause of. If you want a disable to strip them, call the removal path yourself.
One curse per slot. An item carrying two curse: tags applies the first. Supporting several is a few lines — make CurseIdOf return a list and key the bookkeeping on slot plus id — but the single case is what keeps the class readable as a starting point.
It does not stack with itself. Two cursed rings inflicting the same status apply one effect per slot, and the stacking rules on the effect decide what that means. That is the framework's answer, not this recipe's.
Related¶
- Inventory — equipment slots, layouts and filters.
- Status Effects — the ids you can name in a tag, and the stacking rules that decide what two of the same effect do.