An attribute is not a multiplier¶
Health and Status Effects have been publishing seams for "how strong is this character" since long before there was anywhere to keep the number. Now there is one — and plugging it in is not plugging it in. Four seams, four different ideas of what "no change" means, and two of them will silently throw your answer away.
Recipe
Systems required: Attributes, Health, Status Effects. Package: Complete. Shape: one class you drop onto a character that already exists. Public API only. It assumes: it sits on the same GameObject as the health and status components. This is not a preference — see the first warning below, because getting it wrong produces no error of any kind. Once you change it, it is your code. The curves are the part you are supposed to change.
The seams were always there¶
| Seam | Who publishes it | Who already implements it |
|---|---|---|
IAttackerDamageModifier | Health | BerserkBuff |
IHealingModifier | Health | nothing in Runtime/ |
IStatusResistance | Status Effects | an aura zone's internal provider |
IStatusPotency | Status Effects | an aura zone's internal provider |
Each is an offer: tell me how much stronger, tougher or more susceptible this character is, and I will use it. The Attributes documentation says they "all follow the AttributeLevelSource shape, and bridging one is a component of the same size". That is true about the size. This page exists because it is misleading about the work.
They do not agree with each other¶
Read this table before writing the curve, not after
| Seam | Neutral | What the consumer does to your answer |
|---|---|---|
IAttackerDamageModifier | 1 | Multiplies contributions together. A product at or below zero cancels the hit outright. |
IHealingModifier | 1 | Clamps each contribution to [0, 10] (HealingUtility.MaxMultiplier) and substitutes 1 for a non-finite one, then multiplies the clamped answers. The cap is on your number, not on the product — two modifiers answering 10 make a ×100 heal. |
IStatusResistance | 1 | Clamp01. Returning 2 is silently 1 — so the curve for this seam has to fall as the attribute rises. |
IStatusPotency | 1 | Max(0, …), unbounded upward — but only effects implementing IAdjustableMagnitude read it at all. |
Two consequences that catch people:
Vulnerability cannot go through IStatusResistance. It is a 0..1 scale by enforcement and not by convention, so a character who suffers longer effects needs a longer duration at the point of application, and one who suffers stronger effects needs IStatusPotency. The interface's own summary reads like a convention, which is what invites the mistake.
Neutral is 1 on every path, and 0 is never a safe fallback. A seam that cannot answer must say "no change". Returning zero reads as immune, cancelled or blocked depending on which one asked — and on the damage seam it means this character can no longer hurt anything.
Potency reaches fewer effects than the table suggests
The controller applies potency through if (e is IAdjustableMagnitude adj), so an effect that does not opt in ignores your answer completely. Of the statuses RevFramework ships, burn, poison, slow, haste and vulnerability respond — stun and thorns do not. A curve that halves potency does nothing whatever to a stun, and nothing reports that.
Nor does zero block an effect. It applies one at zero magnitude: the status is still applied, still raises its applied event, still takes a stack slot and still shows in a buff bar. It simply does nothing while it runs. Blocking is immunity's job, not potency's.
On the clamped seam, a rising curve is a stat that does nothing
Three of these seams reward a bigger number. IStatusResistance discards every answer above 1, so a curve that rises with the attribute reports full duration for every character at or above its baseline: investing in the stat changes nothing, and the way to resist debuffs becomes draining it. The arithmetic is correct and pointed the wrong way, which is the hardest kind of wrong to see in play — the number moves on the character sheet and nothing happens.
So the status-duration curve below is built by Curve.Falling — neutral at zero of the attribute, half duration at ten points, nothing at twenty — where the other three use Curve.Default. It is the only one of the four that goes down, and the direction belongs to the seam rather than to taste.
An unconfigured curve struct clamps everything to zero
Unity serialises a struct's floats as 0, so a Curve left at its defaults has maximum = 0 and reports a multiplier of zero — which on the damage seam cancels every hit this character throws. That is why the struct has a Default factory and every field below is initialised explicitly. It is a five-minute bug to find and an hour to believe.
The trap that costs the most is where you put the component¶
Half the framework looks up the chain and half does not
IAttributeSource— found withGetComponentInParent. TheAttributeSetmay be on this object or any ancestor.- All four consumed seams — found with
GetComponentson the target object itself:ctx.Attackerfor the damage seam, the heal or status target for the other three.
So this component must be on the same GameObject as HealthSystem and StatusEffectController. One object too low — on a "Visuals" child, say, next to the model — and it compiles, wires, inspects correctly and is never called. There is no error, no warning, and no way to tell from the code.
That is the failure shape that put two recipes on main doing nothing at all: correct code, correctly compiled, never reached. The Cookbook has a convention fixture watching for one version of it now, and this is the version a fixture cannot see.
The damage curve needs a component on the victim, and nothing ships it
IAttackerDamageModifier has exactly one consumer in the framework: AttackerDamageMultiplierRule — and that is a damage rule, so it runs from the target's DamageRuleHub and reads this component off ctx.Attacker. No shipped scene, prefab or bootstrap installs it, and a repo-wide search finds nothing referencing it outside this Cookbook.
So put DamageRuleHub + AttackerDamageMultiplierRule on everything that can be hit, or the damage curve here is read by nobody. It is the placement trap above wearing different clothes: correct code, correctly wired, never called — except this time the missing piece is on the other character, which is why no amount of staring at the attacker finds it.
The other three seams have no equivalent requirement. Healing goes through HealingUtility inside the heal pipeline, and both status seams fall back to StatusUtility when no math service is configured. This is the one seam whose consumer is opt-in.
A StatusProviderAggregator caches its providers, so a late arrival is invisible
The controller resolves its maths as: the serialized slot, else an IStatusMathService on the object, else StatusUtility. The shipped DefaultStatusMathService prefers a StatusProviderAggregator when one is present — and that aggregator caches its provider arrays at OnEnable, rebuilding only when you call Rebuild().
So a character assembled a frame later, or this component added at runtime, is invisible to the status pipeline until something rebuilds it. Call Rebuild() after adding this component to a rig that has an aggregator. The two status curves are the only ones affected; healing and damage do a fresh lookup per call.
There is no framework-wide rule to remember, so check per seam
Crafting's ICraftingLevelSource and ICraftingModifier, Loot's ILootModifier and Attributes' own two seams all walk the parent chain. Health's and Status Effects' modifier seams do not. Both choices are defensible in isolation; there is no single sentence that covers them.
The curve is a design decision, and it is on the component¶
There is no framework-correct answer to what twelve might is worth in damage. So the conversion is 1 + (value − baseline) × perPoint, bounded, per seam — visible in the inspector, tunable without a recompile, and stated rather than implied.
That is the same posture AttributeLevelSource takes for the one attribute adapter the framework does ship: it converts with FloorToInt and says so on the component, because how an attribute maps onto a stat is a decision of your game.
Only one of the four can do situational logic
GetDamageDealtMultiplier is handed the whole DamageContext — attacker, victim, tags, raw amount, whether this is a preview. That is why the damage curve here has a tag filter and the others do not.
IHealingModifier.HealMultiplier is a parameterless property. It cannot vary by heal amount, by healer, or by anything else; the only thing it can depend on is state already sitting on this object. An attribute qualifies. "The second potion heals less" does not — that is healing fatigue, and it needs a second seam to do it at all.
Drop it in¶
using System;
using RevGaming.RevFramework.Attributes.Abstractions;
using RevGaming.RevFramework.Health.Abstractions.Contracts;
using RevGaming.RevFramework.Health.Rules.Abstractions;
using RevGaming.RevFramework.StatusEffects.Abstractions;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.StatToMultiplier
{
/// <summary>
/// Answers four of the framework's stat seams from attributes — each with its own conversion,
/// because the four do not agree on what a multiplier is.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class you drop into a project that already exists. Systems required:
/// <b>Attributes</b>, <b>Health</b>, <b>Status Effects</b>. Public API only.</para>
///
/// <para><b>The seams have been there all along.</b> Health publishes
/// <see cref="IAttackerDamageModifier"/> and <see cref="IHealingModifier"/>; Status Effects
/// publishes <see cref="IStatusResistance"/> and <see cref="IStatusPotency"/>. Each is a standing
/// invitation for something to say how strong this character is, and until Attributes there was
/// nothing holding the number. The Attributes documentation says these "all follow the
/// <c>AttributeLevelSource</c> shape, and bridging one is a component of the same size". That is
/// true about the size and it is the reason this page exists, because it is misleading about the
/// work.</para>
///
/// <para><b>An attribute is not a multiplier, and the four seams disagree about what one is:</b></para>
/// <list type="table">
/// <listheader><term>Seam</term><description>Neutral, range, and who looks</description></listheader>
/// <item><term><see cref="IAttackerDamageModifier"/></term><description><c>1</c> is unchanged;
/// contributions are multiplied together and <b>a product of zero or less cancels the hit
/// outright</b>. Read from <c>ctx.Attacker</c>, and it is handed the whole
/// <see cref="DamageContext"/>.</description></item>
/// <item><term><see cref="IHealingModifier"/></term><description><c>1</c> is unchanged, and the
/// consumer clamps to <c>HealingUtility.MaxMultiplier</c> — <b>10</b> — so a bigger number is
/// silently 10. Read from the heal target, and it is a <b>property with no parameters</b>.</description></item>
/// <item><term><see cref="IStatusResistance"/></term><description><c>1</c> is full duration, and
/// the consumer applies <c>Clamp01</c>. <b>Vulnerability is not expressible here</b>: returning 2
/// is silently 1 — which is also why this is the one seam whose curve has to <i>fall</i> as the
/// attribute rises.</description></item>
/// <item><term><see cref="IStatusPotency"/></term><description><c>1</c> is unchanged, floored at
/// <c>0</c> and <b>unbounded upward</b>. This is where "takes stronger effects" lives — the seam
/// the previous row cannot be. <b>Only effects implementing <c>IAdjustableMagnitude</c> read it
/// at all</b>; of the shipped statuses, <c>stun</c> and <c>thorns</c> do not.</description></item>
/// </list>
///
/// <para><b>The trap that costs the most is where the component goes.</b> All four consumers find
/// their providers with <c>GetComponents</c> on the target object itself — <c>ctx.Attacker</c>
/// for the damage seam, the heal or status target for the other three. Attributes' own
/// <see cref="IAttributeSource"/> is found with <c>GetComponentInParent</c>, on the chain. So
/// this component <b>must sit on the same GameObject as the health and status components</b>,
/// while the <c>AttributeSet</c> it reads may be that object or any ancestor. One object too low
/// and it compiles, wires, looks right and is never called — the failure shape that put two
/// Cookbook recipes on <c>main</c> doing nothing at all.</para>
///
/// <para><b>The curve is the design decision, and it is on the component on purpose.</b>
/// <c>AttributeLevelSource</c> — the one attribute adapter the framework ships — states its
/// conversion (<c>FloorToInt</c>) rather than hiding it, for the same reason. There is no
/// framework-correct answer to what twelve <c>might</c> is worth in damage.</para>
/// </remarks>
[DisallowMultipleComponent]
[AddComponentMenu("RevFramework/Cookbook/Stat To Multiplier")]
public sealed class StatToMultiplier : MonoBehaviour,
IAttackerDamageModifier, IHealingModifier, IStatusResistance, IStatusPotency
{
/// <summary>
/// One attribute, turned into a multiplier: neutral at <see cref="baseline"/>, moving by
/// <see cref="perPoint"/> per point, bounded.
/// </summary>
/// <remarks>
/// <para><b>A blank <see cref="attributeId"/> switches the seam off</b>, and off means
/// neutral — <c>1</c> — not zero. This distinction is the reason the struct has a factory
/// instead of relying on default field values: an unconfigured serialized struct has
/// <see cref="maximum"/> at zero, and a maximum of zero on the damage seam does not mean
/// "no bonus", it means <i>every hit this character throws is cancelled</i>.</para>
/// </remarks>
[Serializable]
public struct Curve
{
[Tooltip("Attribute read for this seam. Blank switches the seam off — it answers neutral.")]
public string attributeId;
[Tooltip("The attribute value that means 'no change'. Ten might with a baseline of ten is a multiplier of 1.")]
public float baseline;
[Tooltip("How much one point above the baseline moves the multiplier. 0.05 is +5% per point.")]
public float perPoint;
[Tooltip("Lower bound on the multiplier this seam reports.")]
public float minimum;
[Tooltip("Upper bound on the multiplier this seam reports. Note each seam's own clamp.")]
public float maximum;
/// <summary>A curve with sane bounds, used as the inspector default for each seam.</summary>
/// <remarks>
/// Rises: ten points of the attribute is neutral, and every point above that is worth
/// <c>+5%</c>. Right for the three seams where a bigger number means more.
/// </remarks>
public static Curve Default(string id, float lower, float upper) => new Curve
{
attributeId = id,
baseline = 10f,
perPoint = 0.05f,
minimum = lower,
maximum = upper,
};
/// <summary>The same curve pointed the other way, for a seam whose consumer clamps at 1.</summary>
/// <remarks>
/// <para><b>Falls: zero of the attribute is neutral, ten points is half, twenty is
/// nothing.</b> On <see cref="IStatusResistance"/> the consumer runs <c>Clamp01</c>, so
/// every answer above 1 is discarded — a rising curve there reports 1 for any character
/// at or above its baseline, which means investing in the stat does nothing and
/// <i>draining</i> it is what resists. That is a correct curve pointed the wrong way,
/// and it reads in play as a stat that does not work.</para>
///
/// <para>Only the clamped-at-one seam needs this. It is a separate factory rather than a
/// negative <see cref="perPoint"/> typed into three inspectors because the direction is
/// a property of the seam, not a preference.</para>
/// </remarks>
public static Curve Falling(string id, float lower, float upper) => new Curve
{
attributeId = id,
baseline = 0f,
perPoint = -0.05f,
minimum = lower,
maximum = upper,
};
}
[Header("Health — attacker side")]
[Tooltip("Scales damage this character deals. Read from ctx.Attacker, so this component must " +
"be on the attacking object. A reported 0 cancels the hit outright.")]
[SerializeField] private Curve damageDealt = Curve.Default("might", 0.1f, 5f);
[Tooltip("Only apply the damage curve to hits carrying these tags. None applies it to every " +
"hit. This filter exists because this is the ONE seam of the four that is handed a " +
"context — the other three cannot do situational logic at all.")]
[SerializeField] private DamageTag damageTagFilter = DamageTag.None;
[Header("Health — receiving side")]
[Tooltip("Scales healing this character receives. The consumer clamps at 10, so a larger " +
"maximum here is silently 10.")]
[SerializeField] private Curve healingReceived = Curve.Default("vitality", 0.1f, 3f);
[Header("Status Effects — receiving side")]
[Tooltip("Scales the DURATION of statuses applied to this character. Clamp01 by the consumer: " +
"0 blocks, 1 is full duration, and anything above 1 is silently 1 — so this is the " +
"one curve that FALLS. More resilience is less duration; a rising curve here would " +
"make raising the stat do nothing and draining it the buff.")]
[SerializeField] private Curve statusDuration = Curve.Falling("resilience", 0f, 1f);
[Tooltip("Scales the MAGNITUDE of statuses applied to this character. Floored at 0 and " +
"unbounded upward — this is where 'takes stronger effects' has to live. Only " +
"effects implementing IAdjustableMagnitude respond: shipped stun and thorns do not.")]
[SerializeField] private Curve statusPotency = Curve.Default("", 0f, 4f);
private IAttributeSource _source;
// =====================================================================
// Health
// =====================================================================
/// <summary>
/// The multiplier this character's attack carries.
/// </summary>
/// <remarks>
/// <para><b>Nothing reads this until the character being hit carries the rule that asks.</b>
/// <c>AttackerDamageMultiplierRule</c> is the only consumer of
/// <see cref="IAttackerDamageModifier"/> in the framework, and it is a <i>damage rule</i> — so
/// it runs from the <b>victim's</b> <c>DamageRuleHub</c> and reads this component off
/// <c>ctx.Attacker</c>. No shipped scene, prefab or bootstrap installs it. Put the hub and
/// the rule on everything that can be hit, or this curve is read by nobody and nothing says
/// so. The other three seams have no equivalent requirement: healing runs through
/// <c>HealingUtility</c> inside the heal pipeline, and both status seams fall back to
/// <c>StatusUtility</c> when no math service is configured.</para>
///
/// <para><b>Contributions are multiplied together by <c>AttackerDamageMultiplierRule</c>, and
/// a combined product at or below zero cancels the hit.</b> So the interesting bound is the
/// lower one: a curve that can reach zero is a curve that can make this character unable to
/// damage anything, which is a legitimate mechanic and a surprising accident. The default
/// minimum is deliberately above zero.</para>
///
/// <para><b>This is the only one of the four handed a context</b>, which is why the tag filter
/// exists here and nowhere else. A non-finite return is ignored by the consuming rule rather
/// than propagated, but returning one would still be a bug in this component, so the shared
/// evaluation refuses it first.</para>
/// </remarks>
public float GetDamageDealtMultiplier(in DamageContext ctx)
{
if (damageTagFilter != DamageTag.None && (ctx.Tags & damageTagFilter) == 0)
return 1f;
return Evaluate(in damageDealt);
}
/// <summary>
/// The multiplier applied to healing this character receives.
/// </summary>
/// <remarks>
/// <para><b>A property, with no parameters at all</b> — where its damage-side twin takes a
/// full <see cref="DamageContext"/>. It cannot vary by heal amount, by healer, or by anything
/// situational; the only thing it can depend on is state already sitting on this object. An
/// attribute qualifies, which is why this bridge is possible at all, and "the second potion
/// heals less" is not expressible here. That is
/// <see href="../HealingFatigue/README.md">healing fatigue</see>'s subject, and it needs a
/// second seam to do it.</para>
///
/// <para><b>Read once per heal by <c>HealingUtility</c>, which clamps this answer into
/// <c>[0, 10]</c> and substitutes 1 for a non-finite one — per contribution, not on the
/// result.</b> Every <see cref="IHealingModifier"/> on the object is clamped that way and
/// the clamped answers are then multiplied, so the ceiling is on your number rather than on
/// the heal: this component answering 3 beside a second modifier answering 5 is a ×15 heal,
/// and nothing downstream trims it — the pipeline bounds only the final amount, to
/// <c>[0, int.MaxValue]</c>. Worth knowing here precisely because
/// <see href="../HealingFatigue/README.md">healing fatigue</see>, the companion this page
/// points at, is another <see cref="IHealingModifier"/> on the same object.</para>
/// </remarks>
public float HealMultiplier => Evaluate(in healingReceived);
// =====================================================================
// Status Effects
// =====================================================================
/// <summary>
/// Scales the duration of an incoming status. Never above 1 in practice.
/// </summary>
/// <param name="statusId">Status being applied.</param>
/// <param name="tags">Its declared tags.</param>
/// <remarks>
/// <para><b>The consumer runs <c>Clamp01</c> over whatever this returns</b>, so this seam can
/// only ever shorten. A character who suffers <i>longer</i> effects needs
/// <see cref="GetMagnitudeMod"/>, or a longer duration at the point of application — reading
/// this seam's name as "resistance, positive or negative" is the mistake it invites.</para>
///
/// <para><b>Which makes this the one curve that has to fall</b>, and why its default comes
/// from <see cref="Curve.Falling"/> where the other three use <see cref="Curve.Default"/>.
/// The clamp discards every answer above 1, so a rising curve reports full duration for
/// every character at or above its baseline: the stat does nothing as it grows, and the way
/// to resist is to have less of it.</para>
/// </remarks>
public float GetModifier(string statusId, StatusTag tags) => Evaluate(in statusDuration);
/// <summary>
/// Scales the magnitude of an incoming status. Unbounded upward.
/// </summary>
/// <param name="id">Status being applied.</param>
/// <param name="tags">Its declared tags.</param>
/// <remarks>
/// <para><b>Only effects that implement <c>IAdjustableMagnitude</c> read this at all.</b> The
/// controller applies potency through <c>if (e is IAdjustableMagnitude adj)</c>, so an effect
/// that does not opt in ignores the answer completely. Of the statuses RevFramework ships,
/// burn, poison, slow, haste and vulnerability respond; <b><c>stun</c> and <c>thorns</c> do
/// not</b>. A curve that halves potency does nothing whatever to a stun.</para>
///
/// <para><b>And zero does not block an effect — it applies one at zero magnitude.</b> The
/// status is still applied, still raises its applied event, still occupies a stack slot and
/// still appears in a buff bar; it simply does nothing while it runs. Blocking belongs to
/// immunity, not to potency.</para>
///
/// <para>Left switched off by default — a blank attribute id — because a project that has not
/// decided what its potency stat is should answer neutral rather than something
/// arbitrary.</para>
/// </remarks>
public float GetMagnitudeMod(string id, StatusTag tags) => Evaluate(in statusPotency);
// =====================================================================
// The conversion, in one place
// =====================================================================
/// <summary>
/// Turns one attribute into one multiplier, or reports neutral.
/// </summary>
/// <remarks>
/// <para><b>Neutral is 1 on every path</b> — a blank id, no attribute source anywhere on the
/// chain, an attribute the owner does not hold, or arithmetic that came out non-finite. A
/// seam that cannot answer must say "no change"; returning 0 from any of these would read as
/// <i>immune</i>, <i>cancelled</i> or <i>blocked</i> depending on which seam asked.</para>
///
/// <para><b>Bounds are ordered rather than trusted</b>, the same shape
/// <see href="../WorstDebuffWins/README.md">only your worst debuff counts</see> uses: an
/// inspector where maximum sits below minimum otherwise produces a clamp that returns the
/// larger of two numbers neither of which is the answer.</para>
///
/// <para><b>The effective value, not the base</b>, so equipment and buffs contributing through
/// <c>IAttributeModifierProvider</c> reach the pipeline — which is the entire point of
/// routing a stat through Attributes rather than reading it off a component.</para>
/// </remarks>
private float Evaluate(in Curve curve)
{
if (string.IsNullOrWhiteSpace(curve.attributeId))
return 1f;
IAttributeSource source = Source();
if (source == null || !source.TryGetValue(curve.attributeId, out float value))
return 1f;
float multiplier = 1f + (value - curve.baseline) * curve.perPoint;
if (!float.IsFinite(multiplier))
return 1f;
float lower = curve.minimum;
float upper = Mathf.Max(lower, curve.maximum);
return Mathf.Clamp(multiplier, lower, upper);
}
/// <summary>
/// The attribute source, resolved lazily and re-resolved while none has been found.
/// </summary>
/// <remarks>
/// Misses are not cached, so an <c>AttributeSet</c> that appears after this component woke
/// up — an additively loaded scene, a character assembled later in the frame — is found on
/// the next query rather than never. The field is interface-typed, so a destroyed component
/// has to be dropped explicitly: Unity's destroyed-object reporting never runs behind a plain
/// null check on an interface, and the null-coalescing operators do not either.
/// </remarks>
private IAttributeSource Source()
{
if (_source is UnityEngine.Object dead && !dead)
_source = null;
if (_source == null)
_source = GetComponentInParent<IAttributeSource>();
return _source;
}
}
}
Wiring it up¶
- Put an
AttributeSeton the character, or on an ancestor of it, and give it the ids you want to drive from —might,vitality,resilience, whatever your vocabulary is. - Wire a combiner if anything is going to contribute. Without one, effective values are clamped base values and providers are not consulted at all: three stacking rules, one container.
- Put this component on the same GameObject as the health and status components. Read the danger box above if you are tempted to tidy it onto a child.
- Fill in the curves you want and leave the
attributeIdblank on the ones you do not. Blank means neutral, which is the correct way to switch a seam off; a curve pointing at an attribute nobody has is also neutral, so a half-built character behaves rather than breaking. Keep the status-duration curve'sperPointnegative: on that seam a rising curve is a stat that does nothing. - Set the damage curve's minimum above zero unless "this character can be made unable to deal damage" is a mechanic you want.
- Give everything that can be hit a
DamageRuleHubwith anAttackerDamageMultiplierRuleon it — on the victim, not on this character. Without it the damage curve is never read. The healing and status curves need no such step. - Check the numbers with
HealingUtility.GetHealingMultiplierandStatusUtility.ComputeFinalDurationScale. Both are public and side-effect free, and the healing one is precisely what the heal pipeline calls. The status one is what the pipeline calls unless the character carries aStatusProviderAggregator, which answers from a cached list — so it can report a working adapter the pipeline is not yet using. See the aggregator warning above.
What it deliberately does not do¶
It does not implement IDamageAffinity. That seam is per damage tag and returns a nullable, and a single attribute is the wrong shape for it: affinity is a table of resistances, and AffinityProfileProvider already reads one from an asset. Bridging it to attributes means one attribute per tag, which is a design decision this page would rather not make for you.
It does not touch max health. Health is the sole writer of max health, and there is no contributor seam for it. A Cookbook recipe that invented one would be a production API change wearing a recipe's clothes.
It does not stack with itself. One of these per character. Two would multiply, which is legal and almost certainly not what anyone meant — [DisallowMultipleComponent] says so.
It does not read the base value. Every curve reads the effective value, so gear and buffs contributing through IAttributeModifierProvider reach the damage pipeline. That is the entire reason to route a stat through Attributes instead of reading it off a component.
It does not cache. Every curve resolves the source once and then reads it per call. The reads are frequent — per hit, per heal, per status application — and the cost is one dictionary lookup plus whatever your providers do, which is the budget the seams ask for and the reason the equipment provider says what it says about staying cheap.
Related¶
- Attributes — the container, and the adapters that turn a stored value into a stat.
- Health — the damage pipeline and where attacker-side scaling sits in it.
- Status Effects — potency, resistance, and what each scales.
- A character sheet nothing writes to — where the effective value these curves read comes from.
- Only your worst debuff counts — the other recipe built on "the seam is a standing invitation nobody has accepted".
- Healing that gets weaker the more you receive — what to do when the seam you need cannot see enough to answer.