Skip to content

Standing, and what standing means

The framework owns the value. Your game owns what the value means. Faction reputation is one attribute id per faction and a table of names you wrote — and the game writes no save code, just one line of wiring.

Recipe

Systems required: Attributes, and Save — which lives in Core. Package: Complete. Shape: one file holding one component and its rank struct. Public API only. It assumes: an AttributeSet on the owner, a StableId on it if standing should persist, and AttributesSaveParticipant registered once. 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

There is no reputation system to install, because there is nothing left to build. A faction's standing is an attribute:

attributes.SetBaseValue("standing.thieves_guild", 45f);
attributes.TryGetBaseValue("standing.city_watch", out float watch);

Several factions are several ids on one owner. That is the entire storage model, and everything else in this recipe is about meaning — the ranks, the thresholds, the range, the consequences. None of which the framework has, or should have.

The framework ships no faction, no rank name, no reputation axis and no opinion about what −40 is. It holds a signed float per (owner, id) and tells you when one moves. What "Disliked" is, whether helping thieves angers the watch, and whether a shop refuses you are decisions with no correct answer, so it declines to have one.

The game writes almost no save code

Not "a little". None.

AttributesSaveParticipant captures the base values of every owner carrying a StableId, so standing persists with:

  • no payload format
  • no version guard
  • no section key
  • no Capture / Restore pair written here

The one line a project needs is registered once for Attributes as a whole, not once per faction:

manager.Register(new AttributesSaveParticipant());

Compare that with an objective, which does write a participant — because a completion latch is a fact about the game that nothing in the framework owns. Standing is the opposite: the value is the framework's, so persisting it is already done. Knowing which of those two situations you are in is most of the skill in using the save system.

This component still touches the save manager — for one thing

It subscribes to LoadCompleted, and only to recompute what it is showing. That is presentation reconciliation, not persistence, and the distinction matters: see below.

Crossing a rank needs both ends

A rank change is not a question about a value. It is a question about whether a boundary sits between two values, and one number cannot answer it.

AttributeDelta carries before as well as after for exactly this reason, and its own remarks say so:

a consumer that cares about crossing a boundary needs the previous value to know a crossing happened, and adding it to a shipped event later would be a breaking change.

So the rank check compares the rank names either side, not the numbers:

string from = RankNameFor(delta.before);
string to   = RankNameFor(delta.after);

if (from != to)
    RankChanged?.Invoke(delta.id, from, to);

Three consequences fall out of that shape, and all three are what you want:

A jump reports one crossing, not one per band. Going from Hated to Honoured in a single act passes four thresholds and is one promotion. Anything that wants the bands in between can walk them.

A change inside a band is silent. Moving from 41 to 79 is still Liked, and no game wants a notification for it.

First contact establishes a rank without announcing a promotion. A created attribute reports before == after — deliberately, and the framework says why:

the id is the news, and fabricating a previous value the attribute never held would report a crossing that never occurred.

The two rank names therefore match, nothing is announced, and the rank is still recorded. A naive if (after > threshold) check would announce a promotion the first time the player so much as met the faction.

The clamp is in game code, and that is a correction

This is the part worth checking against the source rather than believing, because the inspector will tell you otherwise.

AttributeEntry carries optional min and max, the store applies them, and it is entirely reasonable to conclude that configuring −100 .. +100 on the attribute is how you bound standing.

That is true only for ids authored in the inspector.

AttributeSet's entire write surface is SetBaseValue(string, float). There is no overload, no DefineAttribute, and no way to declare bounds for an id created in code. The store states the consequence in its own comment:

An id created at runtime has no bounds: bounds are authored configuration, and there is no authoring to read them from.

A faction created at runtime is silently unbounded

A faction the game discovers during play, one minted from a save file, or one a designer forgot to add to the inspector list gets no bounds at all. The −100..+100 you believe you configured does not exist for it, standing runs to arbitrary values, and nothing warns.

It cannot be found by reading the inspector, because the inspector shows the authored rows and says nothing about the ones code will create later. It contradicts what you can see.

So the clamp lives in Adjust and SetStanding, in game code — and it is worth separating the observation from the choice, because they answer different questions.

The observation is that runtime-created ids are unbounded and nothing warns. The cost is not really the missing clamp; it is that the inspector shows authored rows and says nothing about the ids your code will mint later, so the behaviour contradicts what you can see.

The choice is that this recipe clamps in game code rather than waiting for a framework that declares bounds from code. That stands on its own: the range is a design decision about your factions, and the code that decides what standing means is the only place that can apply it to an id the inspector never saw. It would still be the right shape for this component even if the framework did offer one.

And it is settled, not merely current. An attribute's configuration — base value and bounds together — comes from authoring. An id minted in code is unconfigured in every respect rather than specially deprived of bounds, and a container that invented a range for a value nobody configured would be deciding something your game owns. This is a boundary on purpose, not a gap waiting to be filled.

Which leaves the part that was ever really the problem: finding out in silence. AttributeSet warns once when SetBaseValue creates an id in a set that authors bounds on any row — so the case where the inspector promises a range and your code mints something outside it announces itself. A set that authors no bounds stays quiet, because it promised nothing.

The probe suite pins the unbounded behaviour as a negative control, so if the framework ever does gain a way to declare bounds from code, this page fails loudly instead of going quietly stale.

Be exact about what that buys, though: it applies the range, it does not enforce it. The clamp binds writes that go through those two methods and nothing else. A direct SetBaseValue — which this recipe explicitly supports, reacts to, and recommends as the framework's own path — is not clamped by anything here, and for a runtime-created id there is nothing else to clamp it either: it is stored as written, captured as stored, and comes back the same, because RestoreSnapshot ends at the same bounds-free SetBase.

An id you did author keeps its authored bounds on all of those paths. The hole is the id the inspector never saw — which is the same id the box above is about, and the reason the two findings are really one.

So treat the bounds as a convention this component keeps, not an invariant your other code can rely on. Making it an invariant means re-clamping from inside the change handler, which is a write issued from inside a write notification and brings its own re-entrancy problem — worth doing deliberately if you need it, and not something to inherit by accident.

The probe suite pins the unbounded behaviour as a negative control, so that if Attributes ever gains a way to declare bounds in code, the page's correction goes stale loudly rather than quietly.

Which is why the lowest band is a floor

The unbounded case reaches further than the value. Anything that writes SetBaseValue directly — exactly what the box above says will happen — can drop standing below every threshold you authored, and a strict reading of "the highest band at or below this value" then answers no band.

Left alone that is a nameless rank: RankWith hands a HUD "", and because "" is not "Hated", RankChanged fires announcing a promotion into nothing. RankNameFor therefore treats the lowest authored band as a floor and reports it for anything beneath it. Authors write a bottom rank meaning "as bad as it gets", not "exactly −100", and reading it that way keeps every reachable standing inside a band a player can actually be shown.

The empty string survives in exactly one case — a component with no named bands authored at all, where there is nothing to report — and that one warns on enable rather than answering quietly.

Restore is silent, and that is your problem to finish

A restore writes base values directly and raises no change events. That is right for Attributes — reading a save must not fire the events that state would normally fire, or loading a game plays every fanfare again — and Health's participant is silent for the same reason.

It is not a rule the whole framework follows, and assuming it is will bite you elsewhere. Currency restores through SetBalance, Inventory writes into the live container, Status Effects re-applies through ApplyStatus, and Crafting completes offline jobs — all four raise as they go. The guarantees matrix has the table. What follows is true of this component because Attributes is silent, not because restores in general are.

The consequence is that anything caching a derived value is stale the moment a load finishes, and nothing tells it.

This component caches rank names — RankWith answers from the cache, not from a live recompute — so a restore leaves every rank reporting the band it was in before the save was read, while the value has already moved. ReconcileRanks() runs from LoadCompleted — the same place a health bar is redrawn — and a separate Reloaded signal fires rather than RankChanged for every faction.

Why cache at all, when the value is right there

A component that recomputes on every read never goes stale and never has to reconcile — and would make this lesson invisible. Real ones cache, because a rank feeds a UI that redraws far more often than standing moves, and because RankNameFor walks the band table every time.

The probe suite pins it from both directions: that the reconcile repairs a stale cache, and that RankWith genuinely reads it — so if the cache were ever removed, the test that says the reconcile matters fails rather than passing on a component with nothing to reconcile.

Reconcile; do not react. RoomToCarry documents the same discipline from the Inventory side.

What the game owns, listed plainly

Because the answer to "where is the reputation system" is "here, and it is thirty lines":

Standing ids standing.thieves_guild is a string you chose. Nothing registers it
What moves it Quests, thefts, kills, gifts — your game raises them, Adjust applies them
The range Yours, clamped here, for the reason above
Rank names and thresholds Yours. The framework has no view on how many bands or what they are called
Consequences Prices, dialogue, guards, gates. None of it is in this file
Rival propagation Helping thieves angering the watch is two Adjust calls in your own handler

Rival propagation is deliberately not built

Two Adjust calls in the handler that already knows what the player did is the whole feature.

A general rival graph — factions with typed relationships and a propagation rule — is a content model, and building one inside a recipe would be exactly the shadow framework this category exists to demonstrate you do not need. Write the two lines.

Drop it in

FactionStanding.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Attributes.Abstractions;
using RevGaming.RevFramework.Attributes.UnityIntegration;
using RevGaming.RevFramework.Core.Save;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.WhatStandingMeans
{
    /// <summary>
    /// One rank band: a name, and the standing at which it begins.
    /// </summary>
    /// <remarks>
    /// Entirely the game's. The framework ships no rank names, no tiers and no opinion about how many
    /// there should be — it holds a number, and everything here is what your game decided that number
    /// means.
    /// </remarks>
    [Serializable]
    public struct StandingRank
    {
        [Tooltip("What your game calls this band. Shown to players; never compared by the framework.")]
        public string name;

        [Tooltip("Lowest standing that counts as this rank. Bands are read in ascending order, so the " +
                 "highest threshold at or below the current value wins.")]
        public float atLeast;
    }

    /// <summary>
    /// Faction standing, held by Attributes and given meaning here. The framework owns the value; the
    /// game owns what it means.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Attributes</b>, and <b>Save</b> which lives in Core. Public API only.</para>
    ///
    /// <para><b>The whole system is an id and a float.</b> A faction's standing is an attribute:
    /// <c>standing.thieves_guild</c> on the player, moved with <c>SetBaseValue</c>, read with
    /// <c>TryGetBaseValue</c>. Several factions are several ids on one owner. There is no reputation
    /// system to install, no faction to register, and nothing to keep in sync — which is the point,
    /// and also why almost nothing of this class is about storing anything.</para>
    ///
    /// <para><b>The game writes no save code, and one line of wiring.</b> No payload format, no
    /// version guard, no section key, no <c>Capture</c>/<c>Restore</c> pair — the exact things that
    /// make persistence expensive. What it does need is
    /// <c>manager.Register(new AttributesSaveParticipant())</c>, once for Attributes as a whole and
    /// not once per faction, and that participant lives in <c>Integrations/</c> behind the Attributes
    /// and Save gate rather than in Core. "None" was the wrong word for that; this is the right
    /// one. <c>AttributesSaveParticipant</c>
    /// captures the base values of every owner carrying a <c>StableId</c>, so standing persists with no
    /// payload format, no version guard, no section key and no <c>Capture</c>/<c>Restore</c> pair
    /// written here. The one line a project needs is
    /// <c>manager.Register(new AttributesSaveParticipant())</c>, and it is registered once for
    /// Attributes as a whole rather than once per faction. This class subscribes to
    /// <c>LoadCompleted</c>, but only to know when to recompute what it is showing.</para>
    ///
    /// <para><b>What the rank logic cannot see.</b> It reads <c>BaseValueChanged</c>, so it reacts to
    /// the base value and nothing else. Standing composed through the other half of Attributes —
    /// modifier providers and a combiner, the <c>SumOfParts</c> shape — moves the <i>effective</i>
    /// value, and no event reports that. A charm that grants +20 standing with a faction changes what
    /// the player is worth to it and changes no rank here. That is the known gap in Attributes rather
    /// than a decision of this recipe's, and a game that needs it recomputes ranks at the points it
    /// knows a modifier changed.</para>
    ///
    /// <para><b>Crossing a rank needs both ends, and <c>AttributeDelta</c> carries both.</b> A rank
    /// change is not a question about a value — it is a question about whether a boundary sits between
    /// two values, which one number cannot answer. <c>before</c> exists for exactly this, and this is
    /// the first thing in the tree to read it.</para>
    ///
    /// <para><b>A jump reports one crossing, not one per band.</b> Going from hated to honoured in a
    /// single act passes several thresholds; a rank change is a change of rank, so it is announced once
    /// with the two ends. Anything that wants the bands in between can walk them itself.</para>
    ///
    /// <para><b>A created attribute reports <c>before == after</c>, deliberately.</b> The framework's
    /// own remarks say why: the id is the news, and fabricating a previous value the attribute never
    /// held would report a crossing that never occurred. So first contact with a faction establishes a
    /// rank without announcing a promotion, which is almost certainly what you want and is definitely
    /// not what a naive before/after comparison would do.</para>
    ///
    /// <para><b>The clamp is here, in game code. One observation and one choice, kept apart.</b>
    /// <c>AttributeEntry</c> carries optional <c>min</c> and <c>max</c>, and the store applies them —
    /// <i>for ids that were authored in the inspector</i>. <c>AttributeSet</c>'s entire write surface is
    /// <c>SetBaseValue(string, float)</c>, with no way to declare bounds for an id created in code, and
    /// the store says so in its own comment: <i>"An id created at runtime has no bounds: bounds are
    /// authored configuration, and there is no authoring to read them from."</i></para>
    ///
    /// <para><b>The observation:</b> a faction the game discovers at runtime — or one a designer forgot
    /// to add to the inspector list — is silently <b>unbounded</b>, the −100..+100 range you believe
    /// you configured does not exist for it, and nothing warns. That last part is the real cost: the
    /// inspector shows authored rows and says nothing about the ids code will mint later, so this
    /// contradicts what you can see.</para>
    ///
    /// <para><b>The choice:</b> this recipe clamps in <see cref="Adjust"/> and
    /// <see cref="SetStanding"/> rather than asking for the framework to grow a way to declare bounds
    /// in code. That stands on its own — the range is a design decision about <i>your</i> factions, and
    /// the code that decides what standing means is the only place that can apply it to an id the
    /// inspector never saw — and it would remain the right shape for this component even if the
    /// framework did offer one.</para>
    ///
    /// <para><b>And it is settled, not merely current.</b> An attribute's configuration — its base
    /// value and its bounds together — comes from authoring. An id minted in code is unconfigured in
    /// every respect rather than specially deprived of bounds, and a container that invented a range
    /// for a value nobody configured would be deciding something the game owns. So this is a
    /// boundary, deliberately, and not a gap waiting to be filled.</para>
    ///
    /// <para>Which leaves only the part that was ever really the problem: finding out in silence.
    /// <c>AttributeSet</c> now warns once when <c>SetBaseValue</c> creates an id in a set that
    /// authors bounds on any row — so the case where the inspector promises a range and your code
    /// mints something outside it announces itself. A set that authors no bounds says nothing,
    /// because it promised nothing.</para>
    ///
    /// <para>The probe suite pins the unbounded behaviour as a negative control, so if the framework
    /// ever does gain a way to declare bounds from code, this page fails loudly rather than going
    /// quietly stale.</para>
    ///
    /// <para><b>What that does not buy is enforcement.</b> The clamp binds writes that go through
    /// <see cref="Adjust"/> and <see cref="SetStanding"/>, and nothing more. A direct
    /// <c>SetBaseValue</c> — which this class explicitly supports and reacts to — is not clamped by
    /// anything here, and for the runtime-created id this whole section is about there is nothing
    /// else to clamp it either: it is stored as written, captured as stored, and comes back the same,
    /// because <c>RestoreSnapshot</c> ends at the same bounds-free <c>SetBase</c>. (An id that *was*
    /// authored keeps its authored bounds on every one of those paths — the hole is the id the
    /// inspector never saw.)</para>
    ///
    /// <para>So the range is a convention this component keeps rather than an invariant it holds, and
    /// code reading standing should treat the bounds as a design intent rather than a guarantee.
    /// Making it an invariant would mean re-clamping inside the change handler, which is a write from
    /// inside a write notification and brings a re-entrancy problem of its own.</para>
    ///
    /// <para><b>What is deliberately not here.</b> No faction registry and no shipped faction ids. No
    /// rank names in the framework's vocabulary. No morality axis, no rival propagation, no dialogue
    /// gate, and no relationship to <c>TeamProvider</c> — standing and hostility are different
    /// questions and conflating them is how a stealth system ends up unable to express a disguised
    /// player.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class FactionStanding : MonoBehaviour
    {
        [Tooltip("The set holding the standing values. Found on this object when left empty.")]
        [SerializeField] private AttributeSet attributes;

        [Tooltip("Attribute ids this component treats as standing. Yours entirely -- the framework " +
                 "ships none and would not know one from a strength score.")]
        [SerializeField]
        private string[] factions =
        {
            "standing.thieves_guild",
            "standing.city_watch",
        };

        [Tooltip("Lowest standing your game allows. Enforced HERE, because an id created in code has " +
                 "no bounds -- see the class remarks.")]
        [SerializeField] private float minStanding = -100f;

        [Tooltip("Highest standing your game allows. Enforced here for the same reason.")]
        [SerializeField] private float maxStanding = 100f;

        [Tooltip("Rank bands, lowest first. Names and thresholds are your game's; the framework has " +
                 "no view on how many there are or what they are called.")]
        [SerializeField]
        private StandingRank[] ranks =
        {
            new StandingRank { name = "Hated",     atLeast = -100f },
            new StandingRank { name = "Disliked",  atLeast = -40f  },
            new StandingRank { name = "Neutral",   atLeast = 0f    },
            new StandingRank { name = "Liked",     atLeast = 40f   },
            new StandingRank { name = "Honoured",  atLeast = 80f   },
        };

        [Tooltip("The save coordinator, used only to know when to recompute after a load. Nothing " +
                 "here writes a save section. Found automatically when left empty.")]
        [SerializeField] private RevSaveManager saveManager;

        /// <summary>
        /// Raised when a faction's rank changes during play: the faction id, the rank left, the rank
        /// reached.
        /// </summary>
        /// <remarks>
        /// Once per change of rank, however many bands the jump crossed. Never raised by a load, and
        /// never by first contact with a faction — see <see cref="Reloaded"/> and the class remarks.
        /// </remarks>
        public event Action<string, string, string> RankChanged;

        /// <summary>Raised after a load, once ranks have been recomputed. Never during play.</summary>
        public event Action Reloaded;

        private readonly HashSet<string> _watched = new(StringComparer.Ordinal);
        private readonly Dictionary<string, string> _rankById = new(StringComparer.Ordinal);
        private readonly List<StandingRank> _ordered = new();

        /// <summary>The ids this component treats as standing.</summary>
        public IReadOnlyCollection<string> Factions => _watched;

        private void OnEnable()
        {
            if (!attributes)
                attributes = GetComponent<AttributeSet>();

            if (!saveManager)
                saveManager = FindAnyObjectByType<RevSaveManager>(FindObjectsInactive.Include);

            RebuildWatchList();
            RebuildRanks();

            if (attributes)
                attributes.BaseValueChanged += OnBaseValueChanged;
            else
                Debug.LogWarning($"[{nameof(FactionStanding)}] '{name}' has no {nameof(AttributeSet)}, " +
                                 "so it holds no standing at all.", this);

            if (saveManager)
                saveManager.LoadCompleted += OnLoadCompleted;

            ReconcileRanks();
        }

        private void OnDisable()
        {
            if (attributes)
                attributes.BaseValueChanged -= OnBaseValueChanged;

            if (saveManager)
                saveManager.LoadCompleted -= OnLoadCompleted;
        }

        /// <summary>Current standing with a faction, or zero when there is none yet.</summary>
        public float StandingWith(string factionId)
            => attributes && attributes.TryGetBaseValue(factionId, out float v) ? v : 0f;

        /// <summary>
        /// The rank this component currently believes a faction is at.
        /// </summary>
        /// <remarks>
        /// <para><b>Reads the cache, not the live value, and that is the whole point of the reconcile
        /// below.</b> Ranks are maintained by reacting to <c>BaseValueChanged</c>, so anything that
        /// changes a standing WITHOUT raising that event — a restore, most importantly — leaves this
        /// answering with the rank from before. <see cref="ReconcileRanks"/> is what fixes it, and
        /// <c>LoadCompleted</c> is when.</para>
        ///
        /// <para>Computing live instead would make the staleness impossible and the lesson invisible:
        /// a component that never caches never has to reconcile, and most real ones do cache, because
        /// the rank feeds a UI that redraws far more often than standing moves.</para>
        /// </remarks>
        public string RankWith(string factionId)
            => _rankById.TryGetValue(factionId, out string rank) ? rank : RankNameFor(StandingWith(factionId));

        /// <summary>
        /// Moves a faction's standing by <paramref name="delta"/>, clamped into the game's range.
        /// </summary>
        /// <remarks>
        /// <para>The clamp happens here rather than being configured, because an id created in code has
        /// no bounds — the class remarks give the framework's own words for it. Reading, adding and
        /// clamping in one place is what brings the range to every faction, including the ones nobody
        /// remembered to author.</para>
        ///
        /// <para><b>Brings, not enforces, and the difference is worth being exact about.</b> Nothing
        /// about this method is privileged: any code anywhere may call <c>SetBaseValue</c> on a
        /// standing id directly, and the rank logic will still notice — because it reacts to the
        /// attribute's own change event rather than to this method. That is the framework owning the
        /// value and the game owning the meaning, working as intended. It also means such a write is
        /// not clamped by anything, is saved as written, and comes back the same. The bounds hold for
        /// standing that moves through here; they are a convention elsewhere.</para>
        /// </remarks>
        /// <returns>The standing after the change.</returns>
        public float Adjust(string factionId, float delta)
        {
            if (!attributes || string.IsNullOrWhiteSpace(factionId))
                return 0f;

            float current = StandingWith(factionId);
            float next = Mathf.Clamp(current + delta, minStanding, maxStanding);

            // Mathf.Clamp cannot filter a NaN -- both of its comparisons are false against one, so it
            // hands the NaN straight back. AttributeSet then refuses the write, loudly and correctly,
            // and returns false. Reporting `next` regardless would answer "the standing after the
            // change" with a value nothing stored: a HUD would print NaN for a faction whose real
            // standing never moved. The write's own answer decides what is returned.
            return attributes.SetBaseValue(factionId, next) ? next : current;
        }

        /// <summary>Sets a faction's standing outright, clamped into the game's range.</summary>
        public float SetStanding(string factionId, float value)
        {
            if (!attributes || string.IsNullOrWhiteSpace(factionId))
                return 0f;

            float current = StandingWith(factionId);
            float next = Mathf.Clamp(value, minStanding, maxStanding);

            // Same reasoning as Adjust: a non-finite value survives the clamp, the store refuses it,
            // and returning `next` anyway would report a standing nothing holds.
            return attributes.SetBaseValue(factionId, next) ? next : current;
        }

        /// <summary>
        /// Recomputes every known rank without announcing anything.
        /// </summary>
        /// <remarks>
        /// <para>The reconcile path. Used on enable and after a load — both are moments where the
        /// values may have moved without this component watching, and neither is a moment to play a
        /// promotion fanfare.</para>
        ///
        /// <para>It is not decoration: <see cref="RankWith"/> answers from the cache this rebuilds, so
        /// skipping it after a load leaves every rank reporting what it was before the save was read.
        /// That is the concrete shape of "a restore raises nothing, so reconciling is your job".</para>
        /// </remarks>
        public void ReconcileRanks()
        {
            _rankById.Clear();

            foreach (string id in _watched)
                _rankById[id] = RankNameFor(StandingWith(id));
        }

        /// <summary>
        /// The rank a value falls in: the highest band whose threshold it reaches.
        /// </summary>
        /// <remarks>
        /// <para><b>The lowest band is a floor, not a threshold with a hole under it.</b> A value below
        /// every authored <c>atLeast</c> reports the lowest band rather than no band at all. That case
        /// is reachable on the path this page recommends: an attribute id created at runtime has no
        /// bounds, so a direct <c>SetBaseValue</c> can put standing anywhere, and a strict reading
        /// would then hand a HUD a rank named <c>""</c> — and announce a <see cref="RankChanged"/>
        /// into it, since <c>""</c> differs from <c>"Hated"</c>.</para>
        ///
        /// <para>Authors write a bottom rank meaning "as bad as it gets", not "exactly -100". Treating
        /// it that way keeps every reachable standing inside a band a player can be shown, and keeps
        /// the empty string out of the public surface entirely.</para>
        ///
        /// <para>The one case that still returns <c>""</c> is a component with no usable bands
        /// authored at all — nothing to name a rank with. <see cref="RebuildRanks"/> warns about that
        /// separately, so the empty string is never the quiet answer.</para>
        /// </remarks>
        public string RankNameFor(float standing)
        {
            if (_ordered.Count == 0)
                return string.Empty;

            string name = _ordered[0].name;

            for (int i = 0; i < _ordered.Count; i++)
            {
                if (standing < _ordered[i].atLeast)
                    break;

                name = _ordered[i].name;
            }

            return name;
        }

        /// <summary>
        /// The whole composition: a change arrives, and the game decides whether it meant anything.
        /// </summary>
        /// <remarks>
        /// <para>Reads <c>before</c> as well as <c>after</c>, which is the only way to know a boundary
        /// was crossed. Reads them once and compares the resulting rank names rather than the numbers,
        /// so a jump across four bands is one promotion and a change inside one band is silence.</para>
        ///
        /// <para>The creation case is handled by the same comparison rather than by a special case:
        /// a created attribute reports <c>before == after</c>, so the two rank names match and nothing
        /// is announced — while the rank itself is still recorded.</para>
        /// </remarks>
        private void OnBaseValueChanged(AttributeDelta delta)
        {
            if (!_watched.Contains(delta.id))
                return;

            string from = RankNameFor(delta.before);
            string to = RankNameFor(delta.after);

            _rankById[delta.id] = to;

            if (!string.Equals(from, to, StringComparison.Ordinal))
                RaiseRankChanged(delta.id, from, to);
        }

        /// <summary>
        /// Raises <see cref="RankChanged"/> so one throwing subscriber cannot silence the others.
        /// </summary>
        /// <remarks>
        /// <para>The house pattern, and the framework applies it to every event it raises —
        /// <c>AttributeSet.SafeInvoke</c> is the same shape. A bare <c>?.Invoke</c> walks the
        /// invocation list until something throws and then stops: the first subscriber takes the
        /// exception, and every subscriber registered after it silently never runs. A quest tracker
        /// that throws on a malformed rank name would take the HUD down with it, and nothing in the
        /// console would connect the two.</para>
        ///
        /// <para>Duplicated per component rather than factored into a shared helper, because a recipe
        /// is one file a reader copies -- the same reason every recipe re-inlines its save
        /// discipline.</para>
        /// </remarks>
        private void RaiseRankChanged(string factionId, string from, string to)
        {
            var subscribers = RankChanged;
            if (subscribers == null)
                return;

            foreach (Delegate d in subscribers.GetInvocationList())
            {
                try
                {
                    ((Action<string, string, string>)d)(factionId, from, to);
                }
                catch (Exception e)
                {
                    Debug.LogException(e, this);
                }
            }
        }

        /// <summary>Raises <see cref="Reloaded"/> with the same isolation.</summary>
        private void RaiseReloaded()
        {
            var subscribers = Reloaded;
            if (subscribers == null)
                return;

            foreach (Delegate d in subscribers.GetInvocationList())
            {
                try
                {
                    ((Action)d)();
                }
                catch (Exception e)
                {
                    Debug.LogException(e, this);
                }
            }
        }

        /// <summary>
        /// Recomputes ranks after a load, and says so — without announcing promotions.
        /// </summary>
        /// <remarks>
        /// <para>A restore writes base values directly and raises no change events, which is what every
        /// participant in the framework does and is the only correct behaviour: reading a save must not
        /// fire the events that state would normally fire, or loading a game hands out every reward and
        /// plays every fanfare again.</para>
        ///
        /// <para>The consequence is that this component's cached ranks are stale the moment a load
        /// finishes, and nothing tells it. <c>LoadCompleted</c> is where that is fixed — the same place
        /// a health bar is redrawn.</para>
        /// </remarks>
        private void OnLoadCompleted(string slot, RevSaveReport report)
        {
            // A fatal load applied nothing to anybody, so the cache is not stale and there is nothing
            // to announce -- reconciling would recompute identical ranks and Reloaded would tell a UI
            // to redraw for a load that did not happen. GameFacts checks this; the two recipes written
            // after it copied everything except this line, so a reader using either as a template
            // inherited the omission. Checked here so the template is right.
            if (report != null && !string.IsNullOrEmpty(report.FatalError))
                return;

            ReconcileRanks();
            RaiseReloaded();
        }

        private void RebuildWatchList()
        {
            _watched.Clear();

            if (factions == null)
                return;

            for (int i = 0; i < factions.Length; i++)
                if (!string.IsNullOrWhiteSpace(factions[i]))
                    _watched.Add(factions[i]);
        }

        /// <summary>
        /// Sorts the authored bands ascending, so the lookup can stop at the first threshold it misses.
        /// </summary>
        /// <remarks>
        /// Sorted here rather than trusting the inspector, because a designer reordering rows is not
        /// making a behaviour change and should not accidentally be making one.
        /// </remarks>
        private void RebuildRanks()
        {
            _ordered.Clear();

            if (ranks == null)
                return;

            for (int i = 0; i < ranks.Length; i++)
                if (!string.IsNullOrWhiteSpace(ranks[i].name))
                    _ordered.Add(ranks[i]);

            _ordered.Sort((a, b) => a.atLeast.CompareTo(b.atLeast));

            // The one configuration in which a rank has no name to report. Standing still moves and
            // still saves; nothing can be shown for it, and RankWith answers "" forever. Said once
            // per enable rather than left for a HUD to display as a blank label.
            if (_ordered.Count == 0)
            {
                Debug.LogWarning($"[{nameof(FactionStanding)}] '{name}' has no named rank bands, so " +
                                 "every rank it reports is the empty string and it can never announce " +
                                 "a rank change.", this);
                return;
            }

            // Two bands sharing a name is a crossing that cannot be announced. RankChanged compares
            // the two rank NAMES rather than the numbers -- deliberately, so a jump across four bands
            // is one promotion rather than four -- which means adjacent bands called the same thing
            // collapse into one as far as any listener is concerned. That is a reasonable thing to
            // author on purpose and an easy thing to do by accident, so it is reported rather than
            // guessed at.
            for (int i = 1; i < _ordered.Count; i++)
            {
                if (!string.Equals(_ordered[i].name, _ordered[i - 1].name, StringComparison.Ordinal))
                    continue;

                Debug.LogWarning(
                    $"[{nameof(FactionStanding)}] '{name}' has two rank bands named '{_ordered[i].name}' " +
                    $"(at {_ordered[i - 1].atLeast} and {_ordered[i].atLeast}). Crossing between them " +
                    "announces nothing, because a rank change is a change of NAME. Rename one, or " +
                    "merge them into a single band.", this);
            }
        }
    }
}

Wiring it up

  1. Put FactionStanding on the actor whose standing you are tracking, beside its AttributeSet.
  2. Give that actor a StableId if standing should survive a reload, and register AttributesSaveParticipant once.
  3. Name your factions and your rank bands in the inspector.
  4. Move standing from wherever your game already knows something happened:
// Completed a job for the guild -- and the watch noticed.
standing.Adjust("standing.thieves_guild", +15f);
standing.Adjust("standing.city_watch",    -10f);

standing.RankChanged += (faction, from, to) =>
    Hud.Say($"{Name(faction)}: {from} → {to}");

standing.Reloaded += () => ReputationScreen.Redraw();
  1. Read it where consequences live:
if (standing.RankWith("standing.city_watch") == "Hated")
    Guards.AttackOnSight();

What it deliberately does not do

It ships no faction ids. Every id here is an example in a serialised field. A component that came with standing.thieves_guild built in would be a component with an opinion about what your world contains.

It has no morality axis. Good and evil, law and chaos, and paragon/renegade are all one axis with a name, which means they are this recipe with different strings. Shipping one would be shipping a worldview.

It is not connected to teams. Standing and hostility are different questions, and a system that conflated them could not express a disguised player, a truce, or a faction that dislikes you and still trades. Whether low standing makes someone hostile is a rule your game writes, in the code that already resolves targets.

It does not gate anything. No price modifier, no dialogue condition, no door. Those are consequences, they belong where the shop and the door are, and each is one comparison against RankWith.

It does not hysteresis-guard the boundaries. Standing that oscillates around a threshold will announce a promotion and a demotion each time it wobbles. Whether that needs a dead band is a feel question about your game, and the fix is a few lines in RankNameFor — deliberately left to you rather than guessed at.