A character sheet nothing writes to¶
Put a ring on and your might goes up by five. Take it off and it goes back down. Drink a potion and it goes up again until the potion runs out. Nowhere in this recipe is there code that adds a bonus, and nowhere is there code that removes one — and that absence is the recipe.
Recipe
Systems required: Attributes, Inventory, Status Effects. Package: Complete. Shape: three small components you drop onto a character that already exists. No prefab, no bespoke scene. Public API only. It assumes: all three sit on the character, beside or above its AttributeSet — providers are found on the owner's parent chain, so anywhere at or above the owner works, while a child of the owner and a sibling branch do not. 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 part that is not obvious¶
IAttributeModifierProvider is a pull seam. Nothing registers with it, nothing pushes into it, and nothing has to be told when a contribution stops being true. Every time an effective value is read, AttributeSet walks the owner's parent chain, asks whatever it finds, and adds the answers up through your combiner. A ring you are no longer wearing is not in the answer, because the provider that would have mentioned it looked at the slot and found it empty.
A provider below the owner is never asked, and nothing says so
GetComponentsInParent searches the object itself and its ancestors. So a provider tidied onto a child — a "Gear" or "Visuals" object under the character — compiles, inspects correctly and is never consulted. There is no error and no warning: the container's one placement-adjacent diagnostic covers the missing combiner, not this.
Beside the AttributeSet, or above it. The combiner is the exception, because it is reached through the AttributeSet's own slot rather than by discovery, so it can live anywhere.
That sounds like a small difference. It is the difference between this recipe and a cursed item, which does the same thing pushed instead of pulled — and which needs a reconciler, a bounded re-entrancy loop, a per-application StatusContext to identify its own effects, and a set of reconcile points its own page tells you to read before shipping — because there are more of them than you would guess.
The bug this recipe cannot have
CharacterEquipment raises OnEquipped for an item that goes into an occupied slot, and raises nothing at all for the item that was swapped out and returned to the bag. A handler that applies on equip and removes on unequip therefore leaks the old item's bonus, permanently, on every swap — which is exactly why the cursed-item recipe reconciles instead of reacting.
There is nothing to leak here. Not "the leak is handled": there is no ledger for a stale entry to sit in.
The price, stated plainly¶
A pull seam runs inside every read. The equipment provider below walks the slot layout and parses strings each time an attribute is read, which is fine at HUD frequency and is not free if the same attribute is read per hit by several damage rules.
If you cache it, the invalidation set is wider than you think
Equipping and unequipping are the obvious triggers and they are not the only ones. A durability change, a stack quantity change, an ItemDefinition edited in play mode, an item database reload, and a save restore all change what this provider should answer while raising neither equipment event.
The shipped version does not cache because the cheapest correct thing is to re-read. Cache in your copy if you have measured a problem, and invalidate on time rather than on events.
Two contributors, and the second one is the point¶
One provider proves nothing. The interesting claim is that the container special-cases nobody: an equipped ring and a running buff arrive at the combiner as the same shape, in provider order, with nothing marking which came from where.
So a third contributor — a shrine, a difficulty setting, a system you write next year — joins by being a component on the chain. Attributes never learns it exists, and neither does anything reading the value.
Neither Inventory nor Status Effects knows this recipe is here
Both are read through surfaces they publish for their own reasons: CharacterEquipment.layout and GetEquipped, and IStatusEffectController.Active. Nothing in either system was written with attributes in mind, and nothing in either system has to change.
Why the numbers live where they do¶
Equipment bonuses are authored on the item. Any ItemDefinition whose tags carry attr:<id>:<number> contributes while it is worn, so a designer makes a new stat item by typing a tag on an asset. There is no list in the component to keep in step with the item database.
Status bonuses are authored on the component, because they have nowhere better to go. IStatusEffect exposes an id, a duration, a remaining time and a stacking rule — and no magnitude. The mapping from "hasted" to "+4 might" is your game's, and the same shipped status means different things in different games.
Read the raw tags, not NormalizedTags
ItemDefinition.NormalizedTags lower-cases everything. Attribute ids are compared ordinally. So a project whose ids read maxStamina would find attr:maxStamina:5 normalised to attr:maxstamina:5, contributing to an attribute that does not exist — no error, no warning, just a ring that does nothing. The provider reads def.tags for exactly that reason, and the cursed item reads them for the same one.
A NaN in one tag would cost every contribution to that attribute
attr:might:NaN and attr:might:Infinity both parse successfully as floats. Handed to the combiner, either poisons the sum — the container detects the non-finite result, reports it once, and serves the base value, so a single typo on a single asset silently switches off every contribution to that attribute from every source.
The provider refuses a non-finite tag at the source, which costs one item its bonus instead. It is a two-line check and it is the difference between a broken ring and a broken character.
Nothing works until you supply a combiner¶
The framework ships no IAttributeCombiner and never will — how contributions stack is a design decision, not a fact about containers, and the argument is on the interface itself.
With no combiner wired, providers are not consulted at all and effective values are clamped base values. That is documented behaviour and it looks exactly like a bug from outside, which is why the container logs one line explaining itself the first time it finds providers and no combiner. If your ring does nothing, that line is the first thing to look for.
The combiner here is the simplest one there is: everything adds. It does not clamp, because the container applies the attribute's authored bounds after the combiner returns and a combiner that clamps defensively narrows those bounds invisibly.
Swap the arithmetic without touching anything else
Three stacking rules, one container drops in two policies that behave completely differently — percentages with a tag taxonomy, and best-buff-worst-debuff — and the useful part is what does not change to accommodate them: not these two providers, not the container, not anything that reads an attribute.
Persistence comes out right, and nobody arranged it¶
Save the game wearing the ring, with the buff running. Reload.
- Attributes restores base values, and only base values.
- Inventory restores the ring, because the ring is Inventory's state.
- Status Effects restores the buff, because the buff is Status Effects'.
The first effective read after the load walks the chain, finds a provider looking at a restored ring and a provider looking at a restored buff, and returns the right number. No participant wrote the total down, so no participant can disagree about it. That is what "Attributes persists base values only" buys, and it is easier to see here than to explain in the abstract.
The one thing you do have to save yourself
A contributor that belongs to no framework system is yours to persist. If you write a provider backed by "blessings the player has collected", nothing else in the save file knows about them, and the reconstruction above silently comes back short. The rule is simple: whoever owns the state saves the state, and a provider is not state — the thing it reads is.
Drop it in¶
using System;
using System.Collections.Generic;
using System.Globalization;
using RevGaming.RevFramework.Attributes.Abstractions;
using RevGaming.RevFramework.Inventory.Data;
using RevGaming.RevFramework.Inventory.Equipment;
using RevGaming.RevFramework.StatusEffects.Abstractions;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.SumOfParts
{
/// <summary>
/// Contributes an attribute bonus for every tag on every item the character is wearing.
/// </summary>
/// <remarks>
/// <para><b>Recipe, part one of three.</b> Systems required: <b>Attributes</b>, <b>Inventory</b>,
/// <b>Status Effects</b>. Public API only. All three components go on the character, beside or
/// above its <c>AttributeSet</c> — never below it. Providers are collected with
/// <c>GetComponentsInParent</c>, so a provider on a child of the owner is silently never
/// consulted.</para>
///
/// <para><b>Nothing here applies a bonus.</b> That is the whole recipe. A modifier provider is a
/// <i>pull</i> seam: the container asks every contributor on the owner's chain what it has to say
/// each time an attribute is read, so an item that is no longer worn is simply not in the answer.
/// There is no ledger to keep, nothing to add on equip, nothing to remove on unequip — and
/// therefore nothing to leak.</para>
///
/// <para><b>Compare <see href="../CursedEquipment/README.md">a cursed item</see>, which is the
/// same idea pushed instead of pulled</b>, and needs a reconciler, a bounded re-entrancy loop and
/// per-application context matching to stay correct across a swap. Status application is a push:
/// something has to remember what it applied so it can take it back, and
/// <c>CharacterEquipment</c> raises no unequip event for an item that was swapped out. None of
/// that exists here, because none of it has to.</para>
///
/// <para><b>The price of a pull seam is that it runs inside every read.</b> This one walks the
/// slot layout and parses strings, which is fine at HUD frequency and is not free if an attribute
/// is read per hit by several damage rules. Cache in your own copy if you measure a problem —
/// and read the note on <see cref="CollectModifiers"/> about what invalidates such a cache,
/// because it is not only equipping.</para>
///
/// <para><b>The bonus is authored on the item, not here.</b> Any <see cref="ItemDefinition"/>
/// whose <c>tags</c> carry <c>attr:<id>:<number></c> contributes: <c>attr:might:5</c>
/// on a ring adds five to <c>might</c> while it is worn. A designer makes a new stat item by
/// typing a tag on the asset, and there is no table in this component to keep in step with the
/// item database.</para>
/// </remarks>
[DisallowMultipleComponent]
[AddComponentMenu("RevFramework/Cookbook/Sum Of Parts/Equipment Attribute Provider")]
public sealed class EquipmentAttributeProvider : MonoBehaviour, IAttributeModifierProvider
{
[Tooltip("The equipment being read. Defaults to one on this GameObject.")]
[SerializeField] private CharacterEquipment equipment;
[Tooltip("Item tag prefix that marks an attribute contribution. \"attr:\" reads " +
"\"attr:might:5\" as five points of might. Attribute ids are compared ordinally, " +
"so the case you type here is the case your AttributeSet must use.")]
[SerializeField] private string tagPrefix = "attr:";
private void Reset() => equipment = GetComponent<CharacterEquipment>();
/// <summary>
/// Appends one contribution per matching tag on each worn item.
/// </summary>
/// <remarks>
/// <para><b>Appends, never clears.</b> The buffer already holds whatever providers earlier in
/// the collection contributed.</para>
///
/// <para><b>The container calls this for one id at a time, and calls it every time.</b>
/// There is no registration and no memo of which provider answers which id, so every live
/// provider on the chain is consulted on every read of every attribute — returning early
/// cannot change how often that happens, only what each call costs. Worth stating plainly
/// because it is the opposite of what a "collect" seam usually implies.</para>
///
/// <para><b>And this provider's cheap case is not cheap.</b> It walks the whole slot layout
/// and parses every tag on every worn item before it can conclude it has nothing to say about
/// an id — the scan <i>is</i> how it finds out. The status provider beside it does filter by
/// id first, on a string compare per row, which is what an inexpensive miss looks like.</para>
///
/// <para><b>If you cache this, the invalidation set is wider than "equipped" and
/// "unequipped".</b> A durability change, a stack quantity change, an item database reload,
/// an <c>ItemDefinition</c> edited in play mode and a save restore all change the answer
/// without either equipment event firing. That is why the shipped version does not cache: the
/// cheapest correct thing is to re-read.</para>
/// </remarks>
public void CollectModifiers(GameObject owner, string attributeId, List<AttributeModifier> modifiers)
{
// Resolved here rather than in Awake because Awake does not run in EditMode, and this
// framework has paid for components that go quietly inert in the editor before. The
// field is Object-typed, so a destroyed CharacterEquipment fails the check and is
// re-resolved rather than throwing on the next read.
if (!equipment)
equipment = GetComponent<CharacterEquipment>();
if (!equipment || string.IsNullOrEmpty(tagPrefix) || string.IsNullOrEmpty(attributeId))
return;
List<CharacterEquipment.SlotConfig> layout = equipment.layout;
if (layout == null)
return;
for (int i = 0; i < layout.Count; i++)
{
ItemStack worn = equipment.GetEquipped(layout[i].slotId);
// IsEmpty covers both an empty slot and a stack whose definition went missing, which
// is the state a deleted ItemDefinition leaves behind.
if (worn.IsEmpty)
continue;
// The authored tags, not NormalizedTags, and the difference is not cosmetic:
// NormalizedTags lower-cases everything, while attribute ids are compared ORDINALLY.
// A project whose ids read "maxStamina" would find "attr:maxStamina:5" normalised to
// "attr:maxstamina:5" and contributing to an attribute that does not exist — no
// error, no warning, just a ring that does nothing.
string[] tags = worn.def.tags;
if (tags == null)
continue;
for (int t = 0; t < tags.Length; t++)
{
if (TryReadContribution(tags[t], attributeId, out float value))
modifiers.Add(new AttributeModifier(value));
}
}
}
/// <summary>
/// Reads one tag as a contribution to <paramref name="attributeId"/>, if that is what it is.
/// </summary>
/// <remarks>
/// <para><b>Parsed with <see cref="CultureInfo.InvariantCulture"/>, which is not optional.</b>
/// The culture-sensitive overload reads <c>attr:might:2.5</c> as <c>25</c> on a machine whose
/// locale uses a comma for the decimal point — a bug that appears only on other people's
/// computers, in a number nobody typed wrong.</para>
///
/// <para><b>A non-finite tag is refused here rather than passed on.</b>
/// <c>attr:might:NaN</c> and <c>attr:might:Infinity</c> both parse successfully under
/// <see cref="NumberStyles.Float"/>. Handing either to the combiner would poison the sum: the
/// container detects the non-finite result, reports it once and serves the base value, so
/// <i>every</i> contribution to that attribute silently stops counting because of one typo on
/// one asset. Refusing it at the source costs one item its bonus instead.</para>
/// </remarks>
private bool TryReadContribution(string tag, string attributeId, out float value)
{
value = 0f;
if (string.IsNullOrEmpty(tag) || !tag.StartsWith(tagPrefix, StringComparison.Ordinal))
return false;
// The last colon separates the number, so an id may contain colons of its own.
int split = tag.LastIndexOf(':');
if (split < tagPrefix.Length || split == tag.Length - 1)
return false;
int idStart = tagPrefix.Length;
int idLength = split - idStart;
// Length first: an ordinal comparison of a prefix would match "might" against "mightier".
if (idLength != attributeId.Length)
return false;
if (string.CompareOrdinal(tag, idStart, attributeId, 0, idLength) != 0)
return false;
string number = tag.Substring(split + 1);
if (!float.TryParse(number, NumberStyles.Float, CultureInfo.InvariantCulture, out value))
{
Debug.LogWarning($"[{nameof(EquipmentAttributeProvider)}] '{tag}' is not a number this " +
"provider can read, so it contributes nothing. Use a plain decimal " +
"with a full stop, e.g. \"attr:might:2.5\".", this);
return false;
}
if (!float.IsFinite(value))
{
Debug.LogWarning($"[{nameof(EquipmentAttributeProvider)}] '{tag}' parses to a " +
"non-finite value, which would stop every contribution to " +
$"'{attributeId}' from counting. Ignored.", this);
value = 0f;
return false;
}
return true;
}
}
/// <summary>
/// Contributes an attribute bonus for each running status effect named in its table.
/// </summary>
/// <remarks>
/// <para><b>Recipe, part two of three.</b> The second contributor, and the point of having two:
/// the container special-cases neither. An equipped ring and a running buff arrive at the
/// combiner as the same shape, in provider order, with nothing marking which came from where.
/// A third contributor — a shrine, a difficulty setting, a system you write next year — joins by
/// being a component on the chain, and Attributes never learns it exists.</para>
///
/// <para><b>Neither Inventory nor Status Effects knows this recipe is here.</b> Both are read
/// through their own published surfaces, and both keep owning their own state — which is what
/// makes the persistence work out. See the page.</para>
///
/// <para><b>The number lives here rather than on the effect,</b> because
/// <see cref="IStatusEffect"/> exposes an id, a duration and a stacking rule, and no magnitude. So
/// the mapping from "poisoned" to "−3 might" has to be authored somewhere, and the project is the
/// right somewhere: the same status can mean different things in different games, and the effect
/// should not have to know your attribute vocabulary to be applied by your potion.</para>
/// </remarks>
[DisallowMultipleComponent]
[AddComponentMenu("RevFramework/Cookbook/Sum Of Parts/Status Attribute Provider")]
public sealed class StatusAttributeProvider : MonoBehaviour, IAttributeModifierProvider
{
/// <summary>One row: while this status is running, contribute this much to this attribute.</summary>
[Serializable]
public struct Row
{
[Tooltip("Status id, as applied. Compared ordinally — every id RevFramework ships is lowercase.")]
public string statusId;
[Tooltip("Attribute id this status contributes to. Compared ordinally.")]
public string attributeId;
[Tooltip("Contribution per running instance. Negative is a debuff; the container imposes no sign convention.")]
public float perInstance;
}
[Tooltip("The controller being read. Defaults to one on this GameObject.")]
[SerializeField] private MonoBehaviour statusController;
[Tooltip("What each status is worth. One row per status/attribute pair.")]
[SerializeField] private Row[] rows = Array.Empty<Row>();
private IStatusEffectController _controller;
private bool _warnedBadController;
private void Reset() => statusController = GetComponent<IStatusEffectController>() as MonoBehaviour;
/// <summary>
/// Appends one contribution per matching row that has at least one instance running.
/// </summary>
/// <remarks>
/// <para><b>Counted by instance, not by row,</b> so a <c>Stack</c> effect applied three times
/// contributes three times. Under <c>Replace</c> — which every id RevFramework ships uses
/// except <c>slow</c> and <c>haste</c> — there is only ever one instance, so the count is
/// one and the distinction costs nothing.</para>
///
/// <para><b>An expired instance is skipped even while it is still listed.</b> An effect that
/// has run out is removed by the controller's own tick, so between running out and being
/// swept it can still appear in <see cref="IStatusEffectController.Active"/>. Reading
/// <see cref="IStatusEffect.IsExpired"/> means the buff stops contributing the moment it ends
/// rather than at the next tick — which matters because nothing raises an event when an
/// effective value changes, so a HUD reading the attribute is the only thing that would have
/// shown the difference.</para>
/// </remarks>
public void CollectModifiers(GameObject owner, string attributeId, List<AttributeModifier> modifiers)
{
if (rows == null || rows.Length == 0 || string.IsNullOrEmpty(attributeId))
return;
IStatusEffectController controller = Controller();
if (controller == null)
return;
IReadOnlyList<IStatusEffect> active = controller.Active;
if (active == null || active.Count == 0)
return;
for (int r = 0; r < rows.Length; r++)
{
Row row = rows[r];
if (string.IsNullOrEmpty(row.statusId) || row.perInstance == 0f)
continue;
if (!string.Equals(row.attributeId, attributeId, StringComparison.Ordinal))
continue;
int running = 0;
for (int i = 0; i < active.Count; i++)
{
IStatusEffect effect = active[i];
if (effect == null || effect.IsExpired)
continue;
if (string.Equals(effect.Id, row.statusId, StringComparison.Ordinal))
running++;
}
if (running > 0)
modifiers.Add(new AttributeModifier(row.perInstance * running));
}
}
/// <summary>
/// The controller, resolved lazily and re-resolved while none has been found.
/// </summary>
/// <remarks>
/// The serialized field is a <see cref="MonoBehaviour"/> so the inspector will accept any
/// component implementing the interface — the same trade <c>AttributeSet</c> makes for its
/// combiner slot, and for the same reason: Unity will not serialise an interface-typed field.
/// A slot filled with something that does not implement it is reported once and ignored,
/// rather than failing silently on every read.
/// </remarks>
private IStatusEffectController Controller()
{
if (_controller is UnityEngine.Object dead && !dead)
_controller = null;
if (_controller != null)
return _controller;
if (statusController)
{
_controller = statusController as IStatusEffectController;
if (_controller == null && !_warnedBadController)
{
// Warned once and then left alone. Clearing the serialized field would stop the
// repeat too, and would also silently rewrite the designer's wiring — so a
// wrongly-filled slot stays wrong and visible rather than becoming an empty slot
// that quietly falls back to whatever is on this object.
_warnedBadController = true;
Debug.LogWarning($"[{nameof(StatusAttributeProvider)}] '{statusController.GetType().Name}' " +
"is assigned but does not implement IStatusEffectController, so no " +
"status contributions are collected. Reported once.", this);
}
return _controller;
}
_controller = GetComponent<IStatusEffectController>();
return _controller;
}
}
/// <summary>
/// Adds every contribution to the base value. The simplest stacking rule there is.
/// </summary>
/// <remarks>
/// <para><b>Recipe, part three of three, and the part without which the other two do nothing.</b>
/// The framework ships no <see cref="IAttributeCombiner"/>, and with none wired an
/// <c>AttributeSet</c> serves clamped base values and does not consult providers at all. That is
/// documented behaviour and it looks exactly like a bug from the outside, which is why the
/// container logs one line explaining itself when it finds providers and no combiner. Wire this
/// into the <c>AttributeSet</c>'s combiner slot.</para>
///
/// <para><b>Which arithmetic is deliberately not the framework's choice</b>, and this one is only
/// the simplest. <see href="../HouseRules/README.md">Three stacking rules, one container</see>
/// swaps in two policies that behave completely differently — and the useful part is that it does
/// so without changing a character of the two providers above, or of the container, or of anything
/// that reads an attribute.</para>
///
/// <para><b>It does not clamp, and that is not an omission.</b> The container applies the
/// attribute's authored bounds <i>after</i> the combiner returns. A combiner that clamps
/// defensively silently narrows those bounds and makes an authored maximum unreachable.</para>
/// </remarks>
[DisallowMultipleComponent]
[AddComponentMenu("RevFramework/Cookbook/Sum Of Parts/Additive Attributes")]
public sealed class AdditiveAttributes : MonoBehaviour, IAttributeCombiner
{
/// <summary>Base value plus every contribution, in provider order.</summary>
/// <remarks>
/// Deterministic, allocation-free and non-throwing, as the seam asks. The modifier list is
/// valid only for the duration of this call; nothing here keeps it.
/// </remarks>
public float Combine(GameObject owner, string attributeId, float baseValue,
IReadOnlyList<AttributeModifier> modifiers)
{
float total = baseValue;
for (int i = 0; i < modifiers.Count; i++)
total += modifiers[i].value;
return total;
}
}
}
Wiring it up¶
- Put an
AttributeSeton the character and author the base values you want —might,carry, whatever your vocabulary is. The framework names none of them. - Add Additive Attributes and drop it into the
AttributeSet's combiner slot. Nothing works until this is done, and the console will say so once. - Add Equipment Attribute Provider. It finds the
CharacterEquipmenton the same object. - Tag your items:
attr:might:5on the ring,attr:might:-2on the cursed helm. Negative values are ordinary — the container imposes no sign convention. - Add Status Attribute Provider and fill in one row per status you want to matter: status id, attribute id, and what one running instance is worth.
- Read the result with
TryGetValuewherever your game needs it. If you want it to reach the damage pipeline, an attribute is not a multiplier is the next page.
Ids are ordinal everywhere, and every id RevFramework ships is lowercase
Status ids, attribute ids and the id half of an item tag are all compared with no normalisation at all. Haste is not haste, and a status that never matches looks exactly like a status that is not running.
What it deliberately does not do¶
It does not tell anyone the value changed. BaseValueChanged fires for base values the container owns; an effective value that moved because a ring was equipped raises nothing, because the container cannot know when a provider's answer changes. Anything that needs to react rather than read has to reconcile at moments it chooses — a bag that grows with the character is that problem in full.
It does not scale contributions by stack quantity or durability. A worn item is worn; the tag is worth what it says. Both are two lines away in your copy, and both are decisions rather than defaults.
It does not read the magnitude of a status, because IStatusEffect does not expose one. A project that wants "a stronger poison is a bigger penalty" needs its own effect type, and can read it from a provider of its own.
It does not let you be consulted less often. Every live provider on the chain is asked about every attribute on every effective read — there is no registration and no memo of which provider answers which id, so returning early changes what a call costs and never how many calls there are. The status provider's miss is genuinely cheap, a string compare per row. The equipment provider's is not: it walks the whole slot layout and parses every tag before it can conclude it has nothing to say, because that scan is how it finds out.
Related¶
- Attributes — the container, the three seams, and why no combiner ships.
- A cursed item — the same idea pushed instead of pulled, and the bookkeeping that costs.
- Three stacking rules, one container — the arithmetic these two providers feed, and two other answers to it.
- An attribute is not a multiplier — getting the number out of the container and into Health and Status Effects.
- A bag that grows with the character — what to do when something has to react to a value that raises no event.