Skip to content

An objective that counts what already happened

"Collect ten herbs" is a subscription, a counter and a latch. The hard part is that the event you were going to use does not mean what you think it means.

Recipe

Systems required: Inventory, and Save — which lives in Core. Package: Inventory, Pickups & Crafting, or Complete. Shape: one file holding one component that is also a save participant. Public API only. It assumes: a SceneInventoryService and a RevSaveManager in a scene that is never unloaded. 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

Every input a quest objective needs is already a public event: container changes, deaths with the attacker attached, wallet movements with a reason, craft completions, attribute thresholds carrying both ends, statuses gained and lost. What is genuinely absent is the tracker — its states, its branching, its completion semantics — and the framework says so in its own source, three times:

// RevSaveManager.cs, the class's own usage example
manager.Register(new MyQuestParticipant());   // yours is not second-class

IRevSaveParticipant names "quest flags, unlocked levels, player settings and anything else" as things that can join a save file. StableId names "a quest id" as a durable identity example. The framework expected you to write this.

So the interesting question is not whether you can. It is whether the obvious version is correct.

It is not.

InventoryDelta is a slot delta, not "items gained"

This is the whole page. OnContainerChanged hands you an InventoryDelta carrying one Change(index, before, after) per changed slot, computed against a shadow snapshot — and operations are wrapped in a defer scope, so one logical action produces one coalesced delta per container.

Four things go wrong if you read that as a haul:

What the player did What the delta looks like What the naive version does
Stack merge — dragged 5 herbs onto a stack of 5 One slot 5→10, another 5→0 Counts 10. Five of them were already yours
Swap — dragged two stacks past each other Two changes that net to zero Counts everything in both slots
Sort — pressed the tidy button Every occupied slot changes at once Counts the entire bag as a fresh haul
Move to a chest Two separate deltas — +N in the chest, −N in the bag Counts the chest's +N and never sees the −N

The last row is the one that survives review, because each individual delta looks completely reasonable.

The failure mode is that it works

Every one of these produces plausible progress. "Collect 10 herbs" ticks up when the player tidies their bag, and completes when they move a stack into a chest and back. Nothing errors, nothing warns, and the bug reads as generosity until somebody notices the objective can be farmed by dragging.

The arithmetic that is right

Per delta, compute the net movement of the one item you care about — reading both ends of every change:

for (int i = 0; i < delta.changes.Count; i++)
{
    InventoryDelta.Change change = delta.changes[i];

    if (change.after.def  == item) net += change.after.quantity;
    if (change.before.def == item) net -= change.before.quantity;
}

A merge nets zero. A swap nets zero. A sort nets zero. Only something actually entering or leaving the container moves the number.

Note it reads def on both sides independently rather than assuming a slot kept the same item — a slot that went from herbs to ore is a loss of herbs, and the naive after-only read would call it nothing.

Why the total, and not a running tally of gains

Correct per-delta arithmetic still gets the cross-container move wrong, and no arithmetic inside one delta can fix it: the two halves arrive as two separate events. Anything summing gains sees the destination's +10, has no idea a matching −10 is on its way, and counts a transfer as a collection.

So the objective tracks how many are held across the watched set, not how many have been gained.

(Which does leave the recipe's own title pulling the other way. "Counts what already happened" is about where the signal comes from — the objective reads events the game was raising anyway rather than asking the player to route collection through it — and not about the arithmetic, which measures what is held. The two readings are not the same, and the title picks the less exact one.)

  • Move 10 herbs from a watched chest to a watched bag → chest delta takes the total to 90, bag delta puts it back to 100. It dips and returns. It never rises.

    The ordering there is behaviour, not contract. Move defers only the destination container's event, so the source's −10 lands first and the dip comes before the return. Flip that and the figure would spike to 110 before settling — a >= target would complete on the spike. It is pinned incidentally by the watched-move probe, which would read 16 rather than 8 under a flip, so a change would be caught; it is not, however, something the Inventory API promises. - Move 10 herbs in from a container the objective does not watch → the total rises by 10. That is a real acquisition, and counting it is right rather than a compromise.

The objective completes the first time the total reaches its target, and latches — spending the herbs afterwards does not un-complete it, because what already happened already happened.

One meaning, chosen deliberately

"Hold ten at once" and "acquire ten in total over the run" are different objectives, and games want both. This recipe implements the first, because it is the one that is correct without a second ledger — and because a cumulative version has to answer what happens when the player sells ten and buys them back, which is a design question rather than an arithmetic one.

Changing it is your call and it is a small change. Knowing which one you have chosen is the part that matters.

Rejecting cheaply is a feature

OnContainerChanged fires for every container of every owner — chests being restocked, shop stock, corpses, other actors' bags. An objective that did its arithmetic before asking whose delta it was would pay for all of it.

The order is owner, then container, then item — cheapest discriminator first:

if (IsComplete) return;                                  // already done: nothing can change that
if (delta.owner != Owner || delta.IsEmpty || !item) return;
if (!_watched.Contains(delta.container)) return;         // a string compare
                                                         // ...only now, the loop

A completed objective costs one boolean per delta for the rest of the session. That matters when a game has forty of these.

It has one visible consequence, and it is the one you want: Held stops moving once the objective completes. It reads the figure it completed on, so a tracker shows 10 / 10 and stays there rather than climbing to 30 / 10 as the player keeps picking herbs up.

Completion latches before it announces

The completion event is raised from inside Inventory's own dispatch, and the obvious thing to do with it is grant a reward — which writes to Inventory, which raises another delta, which arrives here before the first call has returned.

IsComplete = true;          // first

if (announce)
    Completed?.Invoke(this);   // then

Set the flag first and the re-entrant delta finds an objective that is already complete and returns at the first line. Announce first and it completes twice, and hands out two rewards.

This is the same hazard recipe 41 documents from the other side — its Clear() copies its key list because a subscriber may write from inside the callback. Any event raised from inside a system's dispatch has it.

The save section is one boolean

{"kind":"gather-objective","complete":true}

That is the entire payload, and the restraint is the point. The only thing that cannot be derived after a load is whether this objective had already been completed and reported, so that is the only thing written. Re-deriving the total costs one scan of a few slots.

A participant that saves what another participant already owns is a participant that can disagree with it — and the copy the player can see is the one that wins the argument.

Check that something actually persists every container you watch

It is tempting to finish that thought with "the containers are Inventory's to save, and it already does". That is narrower than it sounds.

InventorySaveParticipant captures each CharacterInventory carrying a StableId, and a CharacterInventory binds exactly one container — the one named on its inspector field. So an objective watching a backpack and a chest has one of them persisted unless the owner carries a CharacterInventory per container, each with its own StableId.

Watch a container nothing persists and the total re-derives short after every load. Worse if the objective had already completed: a completed objective stops counting, so the short figure is then frozen for the rest of the session.

The stamp is not decoration

A section is addressed only by its key, two participants can choose the same key, and JsonUtility never fails on a document it does not recognise: a missing bool comes back false, which reads as a perfectly ordinary incomplete objective.

Without one field a foreign document cannot supply, somebody else's payload silently un-completes this — and the next save writes the loss out. One store, many facts carries the general rule and the defect that found it.

Restore is silent — but a load is not

Restore writes the flag and raises nothing, which is what a restore should do: reading a save must not fire the events that state would normally fire, or a reload hands out every reward again.

Do not extend that to "a load raises nothing". It does not, and this is the sharpest thing on the page. The framework's participants restore by writing into the live containers, so a load raises real OnContainerChanged deltas — InventorySaveParticipant's own remarks concede it:

the container and equipment raise change events as they are written

An objective that stays subscribed through a load therefore sees a bag being refilled and reads it as a haul. So this one stops counting while its own section is being restored, and resumes at LoadCompleted:

public void Restore(string payload, int version)
{
    ...
    IsComplete = parsed.complete;
    _restoring = true;          // deltas from here are the load, not the player
}

private void OnLoadCompleted(string slot, RevSaveReport report)
{
    _restoring = false;
    Rescan();                   // the total comes from the containers, not the payload
    Reloaded?.Invoke(this);
}

That guard is partial — one of its two holes is closeable, and the other is not

The flag only covers deltas arriving after this participant's Restore. Two cases fall outside it, and they have different answers.

Inventory restores first — closed, by declaring an order. Everything this component uses to tell a load from a player is put in place by its own Restore: the completion flag it reads back, and the suspend flag. Whichever of the two would cover a given case only covers it if this participant went first.

That used to be decided by registration order, and the save coordinator's own usage example registers Inventory before anything of yours, printed right under a line saying order does not matter. So the correct behaviour was available by luck and the ordinary wiring was the unlucky one.

GatherObjective implements IRevSaveOrdered with RestoreOrder => RevSaveOrder.Early, which is exactly what that interface is for — its own remarks say a requirement like this has to live on the participant that has it, because the caller has no way to know. Everything that does not ask to move stays put around it, so this is not a change anyone else has to accommodate.

A save with no section for this objective — closed since 1.3.0. A save that predates the objective never calls its Restore, so the local _restoring flag is never armed whatever the order, and a restore delta could complete the objective mid-load. InventoryDelta still carries no cause the way CurrencyAuditEntry does — but RevSaveRestore.InProgress is true for the whole of any participant's Restore, including the inventory participant's, which is what raises the delta. The guard asks the framework as well as itself.

Keep the local flag too: it is the cheaper read, it is what the ordering argument above is about, and it covers a restore this component performs on itself.

The advice below still stands regardless. A change a system defers past the end of its own Restore is outside what either flag can see, and the stale completion left by any such case is corrected after the load from the report — but a Completed raised mid-load has already been handed to your listener by then. If a duplicate reward would be expensive in your game, make the grant idempotent rather than trusting any signal to be unique.

That remaining half is a missing seam rather than a mistake, and it is recorded as one.

LoadCompleted is also where the total is re-derived, because it is the first moment every participant has finished — re-deriving inside Restore would count whichever containers happened to be restored by then.

Activation latches without announcing

Rescan() runs on enable, and if the target is already held it completes the objective there and then — silently. Completed never fires for that run, so a reward hung off it is never granted.

That is right for the case it exists for: re-entering a scene mid-run, or finishing a load, should not replay a completion. It is a trap if your game can activate an objective onto a bag that already satisfies it — accepting "collect 10 herbs" while holding 12.

Deciding which of those you have is game policy, so the recipe does not guess. If the second case is real for you, check IsComplete right after enabling and grant from there.

A new game does not clear itself

This objective survives scene loads on purpose. Returning to the menu and starting again leaves the last run's completion sitting there, and the first save of the new run writes it out.

ResetForNewGame() is one line at the point your game already knows. It is deliberately not called Reset — that is a Unity message, and the editor calls it when a component is added or when a user picks Reset from the context menu.

A save with no section for this objective does clear it, and that took the report

The other half of the same problem, and the rescan cannot solve this one. Complete the objective, then load a save written before this objective existed: the coordinator never calls Restore for a participant whose key is not in the file, and says nothing about having skipped it. Held re-derives fine. IsComplete does not — it latches, and a rescan only ever sets it. The flag from the session you just left survives into a lineage that never earned it, and your next save writes complete=true into its file.

RevSaveReport is what closes it. LoadCompleted hands over the outcomes, and an outcome carrying this participant's key is proof Restore ran; no such outcome means the section was absent, so the latch is cleared before the rescan. An objective the restored containers still satisfy simply re-latches — silently, the same rule activation follows.

Tested against FatalError rather than success, deliberately: a fatal load applied nothing to anybody, so clearing would throw away a flag that is still correct, while another participant failing its own section says nothing about whether yours was in the file. One store, many facts works the same case for a store that never reloads with the scene, and the reasoning there is worth reading once.

It is policy, and it is the only policy in the class. A game where an older save should keep an objective already completed deletes the clearing and keeps the rescan.

Drop it in

GatherObjective.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Core.Save;
using RevGaming.RevFramework.Inventory.Abstractions;
using RevGaming.RevFramework.Inventory.Containers;
using RevGaming.RevFramework.Inventory.Data;
using RevGaming.RevFramework.Inventory.UnityIntegration;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.CountsWhatHappened
{
    /// <summary>
    /// One objective: hold a number of one item across a set of containers, and say so once when it
    /// happens. Not a quest system — a subscription, a counter and a latch.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Inventory</b>, and <b>Save</b> which lives in Core. Public API only.</para>
    ///
    /// <para><b>The framework says a quest is yours, three times in its own source.</b>
    /// <c>RevSaveManager</c>'s usage example is literally
    /// <c>manager.Register(new MyQuestParticipant()); // yours is not second-class</c>;
    /// <c>IRevSaveParticipant</c> names "quest flags" among the things that can join a save file; and
    /// <c>StableId</c> names "a quest id" as a durable identity example. Every input an objective needs
    /// is already a public event. What is missing is the tracker, and the tracker is twenty lines.</para>
    ///
    /// <para><b>The trap is that <c>InventoryDelta</c> is a SLOT delta, not "items gained".</b> This is
    /// the whole reason the page exists, and the naive version gets it wrong in four different ways.
    /// The delta carries one <c>Change(index, before, after)</c> per changed slot, computed against a
    /// shadow snapshot, and operations are wrapped in a defer scope so one logical action produces one
    /// coalesced delta <i>per container</i>. So:</para>
    /// <list type="bullet">
    ///   <item><description>A <b>stack merge</b> is one slot going up and another going down. Counting
    ///   changed slots, or counting <c>after.quantity</c>, invents items that were already
    ///   there.</description></item>
    ///   <item><description>A <b>swap</b> is two changes that net to zero — correct only if you do the
    ///   arithmetic rather than reacting to the fact that something moved.</description></item>
    ///   <item><description>A <b>sort</b> is every occupied slot changing at once, and nothing at all
    ///   happening.</description></item>
    ///   <item><description>A <b>move between containers</b> is two separate deltas — plus N in the
    ///   destination, minus N in the source — so an objective that sums positive deltas counts a
    ///   bag-to-chest transfer as a collection.</description></item>
    /// </list>
    ///
    /// <para><b>The arithmetic that is actually right</b> is, per delta, the net change of the one item
    /// this objective cares about: <c>after</c> counted when its definition matches, minus
    /// <c>before</c> counted when its definition matches. Summed across the delta's changes that gives
    /// the container's net movement of that item, and a merge, a swap and a sort all come out at
    /// zero.</para>
    ///
    /// <para><b>And what is tracked is the total held, not a running tally of gains.</b> That is what
    /// closes the cross-container case, which no per-delta arithmetic can close on its own: the two
    /// halves of a move arrive as two separate events, so anything counting gains sees the destination's
    /// plus N and has no idea the matching minus N is coming. Maintaining the total instead means the
    /// figure dips and returns and never rises, so a transfer inside the watched set cannot complete
    /// anything. Moving items in from a container this objective does not watch is a real acquisition
    /// and does count, which is the correct answer rather than a compromise.</para>
    ///
    /// <para><b>Completion latches, and it latches before it announces.</b> Granting a reward from
    /// inside the completion event re-enters Inventory — the event is raised from inside Inventory's
    /// own dispatch — and the resulting delta arrives here before the first one has finished. Setting
    /// the flag first means the re-entrant delta finds an objective that is already complete and says
    /// nothing.</para>
    ///
    /// <para><b>The save section is one boolean, and that is the point.</b> The only thing that cannot
    /// be derived after a load is whether this objective had already been completed and reported, so
    /// that is the only thing written. A participant that saves what another participant already owns
    /// is a participant that can disagree with it.</para>
    ///
    /// <para><b>What Inventory actually saves is narrower than "the containers", and it matters
    /// here.</b> <c>InventorySaveParticipant</c> captures each <c>CharacterInventory</c> that carries a
    /// <c>StableId</c>, and a <c>CharacterInventory</c> binds exactly ONE container — the one named on
    /// its inspector field. So an objective watching several containers has only one of them persisted
    /// unless the owner carries a <c>CharacterInventory</c> per container, each with its own
    /// <c>StableId</c>. Watch a chest that nothing persists and the objective's total re-derives short
    /// after every load — and if it had already completed, that short figure then freezes, because a
    /// completed objective stops counting.</para>
    ///
    /// <para><b>The reward is deliberately not here.</b> This raises <see cref="Completed"/> and stops.
    /// Where the reward comes from, whether it can be refused, what happens when the bag is full and
    /// whether it is granted again on a reload are all decisions with real consequences, and none of
    /// them belongs inside something calling itself an objective tracker.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class GatherObjective : MonoBehaviour, IRevSaveParticipant, IRevSaveOrdered
    {
        [Tooltip("Save section key. Must not change once shipped -- renaming it orphans every save. " +
                 "Use your own prefix so it can never collide with a framework participant.")]
        [SerializeField] private string saveKey = "mygame.objective.gather";

        [Tooltip("Whose inventory this watches. Leave empty to watch this GameObject's own.")]
        [SerializeField] private GameObject owner;

        [Tooltip("The item to count.")]
        [SerializeField] private ItemDefinition item;

        [Tooltip("How many must be held at once for the objective to complete.")]
        [SerializeField, Min(1)] private int target = 10;

        [Tooltip("Containers that count. A move between two of these is not an acquisition; a move in " +
                 "from anywhere else is. At least one is required -- there is no 'everything' option, " +
                 "because the service answers per container and cannot enumerate them.")]
        [SerializeField] private string[] containers = { "backpack" };

        [Tooltip("The scene's inventory service. Found automatically when left empty.")]
        [SerializeField] private SceneInventoryService inventory;

        [Tooltip("The save coordinator. Found automatically when left empty.")]
        [SerializeField] private RevSaveManager saveManager;

        /// <summary>
        /// Raised when the objective completes because something changed during play. The one
        /// completion signal.
        /// </summary>
        /// <remarks>
        /// <para><b>Not raised by <see cref="Rescan"/>, and that includes activation.</b> Enabling this
        /// component while the target is already held latches <see cref="IsComplete"/> without
        /// announcing it — the right answer for re-entering a scene mid-run, and a trap if you expected
        /// a reward for a bag the player filled before the objective existed. If your game can activate
        /// an objective onto an already-satisfied inventory, decide there whether that counts.</para>
        ///
        /// <para><b>Suppressed during a load, as far as the public API allows.</b> A save taken after
        /// completion restores an already-complete objective, and re-announcing would hand out the
        /// reward again. Restoring this participant's own section sets a flag that ignores deltas until
        /// <c>LoadCompleted</c>.</para>
        ///
        /// <para><b>That guard is partial, and what remains of the gap is worth stating exactly.</b>
        /// The framework's participants write into live containers, so a restore raises real inventory
        /// deltas — the Inventory participant's own remarks concede it. The flag only covers deltas
        /// that arrive after this participant's <c>Restore</c>, which leaves two cases, and they no
        /// longer have the same answer.</para>
        ///
        /// <para><i>Inventory restoring first</i> is <b>closed</b>, by declaring
        /// <see cref="RestoreOrder"/> as <c>Early</c> rather than hoping the caller registers in a
        /// helpful sequence. It used to depend on registration order, and the order the coordinator's
        /// own example shows was the losing one.</para>
        ///
        /// <para><i>A save with no section for this objective</i> used to be <b>open</b>: <c>Restore</c>
        /// is never called, so <c>_restoring</c> is never armed whatever the order, and
        /// <c>InventoryDelta</c> carries no cause the way <c>CurrencyAuditEntry</c> does.
        /// <b>Closed since 1.3.0 by <c>RevSaveRestore.InProgress</c></b>, which is true for the whole
        /// of any participant's <c>Restore</c> — including the inventory participant's, which is what
        /// raises the delta. So the guard asks the framework rather than only itself.</para>
        ///
        /// <para>Keep the local flag as well. It is the cheaper read, it is what the ordering argument
        /// above is about, and it still covers a restore this component performs on itself. And keep
        /// the advice that follows regardless: if a duplicate reward would be expensive in your game,
        /// make the grant idempotent rather than trusting any signal to be unique — a deferred change
        /// raised after the participant returns is outside what either flag can see.</para>
        ///
        /// <para>Connect a reward here. <c>IRewardService.Grant</c> with a <c>PriceBundle</c> you build
        /// is the Complete-package route; a direct <c>AddItem</c> is the small one. Both re-enter
        /// Inventory from inside this call, which is safe because the flag is already set.</para>
        /// </remarks>
        public event Action<GatherObjective> Completed;

        /// <summary>Raised after a load, once the objective has re-derived itself. Never during play.</summary>
        /// <remarks>
        /// The counterpart to <see cref="Completed"/>, and separate for the reason recipe 41 gives:
        /// a listener needs to tell "the player just finished this" apart from "a save file was read",
        /// because the first plays a fanfare and the second quietly redraws a tracker.
        /// </remarks>
        public event Action<GatherObjective> Reloaded;

        private readonly HashSet<string> _watched = new(StringComparer.Ordinal);

        private bool _registered;

        /// <summary>
        /// True between this participant's own <see cref="Restore"/> and the end of the load.
        /// </summary>
        /// <remarks>
        /// The framework's participants write into live containers, so a load raises real change
        /// events. This is the only guard the public API allows, and it is partial — see the remarks
        /// on <see cref="Completed"/>.
        /// </remarks>
        private bool _restoring;
        private bool _warnedOwnerGone;

        /// <summary>Whether the objective has been completed. Latches, and survives a save.</summary>
        public bool IsComplete { get; private set; }

        /// <summary>How many are held across the watched containers right now.</summary>
        public int Held { get; private set; }

        /// <summary>How many are needed.</summary>
        public int Target => target;

        /// <summary>Progress in <c>[0..1]</c>, for a bar.</summary>
        public float Progress01 => target <= 0 ? 1f : Mathf.Clamp01(Held / (float)target);

        /// <inheritdoc />
        public string Key => saveKey;

        /// <inheritdoc />
        public int Version => 1;

        /// <inheritdoc />
        /// <remarks>
        /// <para><b>Early, and it is the difference between a guard and a coincidence.</b> Everything
        /// this component uses to tell a load from a player — the restored completion flag, and the
        /// suspend flag — is put in place by its own <c>Restore</c>. Inventory restores by writing into
        /// the LIVE container and raises real deltas doing it, so whichever of the two would have
        /// covered a given case only covers it if this participant went first. Restore Inventory first
        /// instead and the deltas land on a component that still holds last session's state: a bag
        /// being refilled reads as a haul, and a save the player already earned pays out again.</para>
        ///
        /// <para>Which one goes first was otherwise decided by registration order, and the save
        /// coordinator's own usage example registers Inventory before anything of the game's — printed
        /// directly beneath a line saying order does not matter. So the correct behaviour was
        /// available by luck, and the ordinary way of wiring a project was the unlucky one.</para>
        ///
        /// <para><c>IRevSaveOrdered</c> exists precisely so a requirement like this lives on the
        /// participant that has it rather than in the caller's registration sequence — its own remarks
        /// say a caller had no way to know. <c>Early</c> puts this before every participant that does
        /// not ask to move, which is all of them except Crafting (<c>Late</c>).</para>
        ///
        /// <para><b>It does not close the other hole.</b> A save holding no section for this objective
        /// never calls <c>Restore</c> at all, so nothing is armed whatever the order — see the note on
        /// <see cref="Completed"/>. Ordering fixes the case where <c>Restore</c> runs; nothing public
        /// fixes the case where it does not.</para>
        /// </remarks>
        public int RestoreOrder => RevSaveOrder.Early;

        /// <summary>
        /// Whose containers this objective watches: the assigned owner, or this object if none is.
        /// </summary>
        /// <remarks>
        /// The fallback is for the ordinary case of dropping the component straight onto the player.
        /// It is deliberately NOT a recovery route for a destroyed owner: <c>owner</c> is fake-null
        /// once the object it pointed at is gone, so the same expression would quietly re-aim the
        /// objective at whatever this component happens to sit on and go on reporting progress for
        /// the wrong actor's bag. <see cref="OwnerIsGone"/> tells the two apart -- assigned-then-
        /// destroyed is a reference that is non-null to the CLR and false to Unity.
        /// </remarks>
        private GameObject Owner => owner ? owner : gameObject;

        /// <summary>True when an owner was assigned and the object it named has since been destroyed.</summary>
        private bool OwnerIsGone => !ReferenceEquals(owner, null) && !owner;

        private void OnEnable()
        {
            RebuildWatchList();

            if (!inventory)
                inventory = FindAnyObjectByType<SceneInventoryService>(FindObjectsInactive.Include);

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

            if (_watched.Count == 0)
                Debug.LogWarning($"[{nameof(GatherObjective)}] '{name}' watches no containers, so it " +
                                 "can never make progress. Name at least one.", this);

            if (inventory)
                inventory.OnContainerChanged += OnContainerChanged;
            else
                Debug.LogWarning($"[{nameof(GatherObjective)}] '{name}' found no " +
                                 $"{nameof(SceneInventoryService)}, so it will never see a change.", this);

            if (saveManager)
            {
                saveManager.Register(this);
                saveManager.LoadCompleted += OnLoadCompleted;
                _registered = true;
            }
            else
            {
                // The one inert configuration this component did not report. Everything still works
                // for the length of a session -- the objective tracks, completes and announces -- and
                // then the completion is not in the save and the load never reaches it. A silent
                // failure that only shows up after a reload is the expensive kind, and this component
                // warns about every other one.
                Debug.LogWarning($"[{nameof(GatherObjective)}] '{name}' found no {nameof(RevSaveManager)}, " +
                                 "so its completion is not saved and it will start every session from " +
                                 "zero. Tracking still works; persistence does not.", this);
            }

            Rescan();
        }

        private void OnDisable()
        {
            if (inventory)
                inventory.OnContainerChanged -= OnContainerChanged;

            if (_registered && saveManager)
            {
                saveManager.Unregister(this);
                saveManager.LoadCompleted -= OnLoadCompleted;
            }

            _registered = false;
        }

        /// <summary>
        /// Recomputes <see cref="Held"/> from the containers themselves, and completes if that is
        /// already enough.
        /// </summary>
        /// <remarks>
        /// <para>The seeding and reconciliation path. Deltas describe changes, so they can only
        /// maintain a figure that was correct to begin with — an objective enabled halfway through a
        /// run, or one whose owner was loaded from a save, has to start by looking.</para>
        ///
        /// <para><b>It latches without announcing.</b> If the target is already held, this completes
        /// the objective silently and <see cref="Completed"/> never fires for it. That is correct for
        /// the case it exists for — re-entering a scene, finishing a load — and it is a trap if an
        /// objective can be activated onto a bag that already satisfies it, because the reward is then
        /// never granted. Deciding which of those you have is game policy; see
        /// <see cref="Completed"/>.</para>
        /// </remarks>
        public void Rescan()
        {
            if (!inventory || !item)
            {
                Held = 0;
                return;
            }

            // A destroyed owner would otherwise fall through to this component's own GameObject and
            // start counting a different actor's containers -- silently, and with the objective still
            // reporting progress. Reported once and then treated as no progress, because guessing
            // which actor was meant is not something a tracker gets to do.
            if (OwnerIsGone)
            {
                if (!_warnedOwnerGone)
                {
                    _warnedOwnerGone = true;
                    Debug.LogWarning(
                        $"[{nameof(GatherObjective)}] '{name}' had an owner assigned and that object has " +
                        "been destroyed. It is NOT falling back to its own GameObject, because that " +
                        "would count a different actor's containers and go on reporting progress. " +
                        "Assign a live owner, or leave the field empty to mean this object.", this);
                }

                Held = 0;
                return;
            }

            int total = 0;

            foreach (string id in _watched)
            {
                var container = inventory.Get(Owner, id);
                if (container == null)
                    continue;

                var slots = container.Slots;
                for (int i = 0; i < slots.Count; i++)
                {
                    ItemStack stack = slots[i].stack;
                    if (stack.def == item)
                        total += Mathf.Max(0, stack.quantity);
                }
            }

            Held = total;
            TryComplete(announce: false);
        }

        /// <summary>Clears progress for a new game. Yours to call, at the point your game knows.</summary>
        /// <remarks>
        /// <para>State that outlives a scene does not clear itself, and this objective deliberately
        /// survives one. Returning to the menu and starting again leaves the last run's completion
        /// sitting here until something says otherwise.</para>
        ///
        /// <para>Named for the new game rather than called <c>Reset</c>, because <c>Reset</c> is a Unity
        /// message: the editor calls it when the component is added and when a user picks Reset from
        /// the context menu. A public method with that name would quietly wipe a designer's progress
        /// while they were looking at the inspector.</para>
        /// </remarks>
        public void ResetForNewGame()
        {
            IsComplete = false;
            Rescan();
        }

        /// <summary>
        /// The delta handler. Cheap to reject, and correct when it does not.
        /// </summary>
        /// <remarks>
        /// The rejection order matters more than it looks. A busy game raises this for every container
        /// of every owner — chests, shops, corpses, other actors — and an objective that measured the
        /// arithmetic before checking whose delta it was would pay for all of them. Owner first, then
        /// container, then the item.
        /// </remarks>
        private void OnContainerChanged(InventoryDelta delta)
        {
            // RevSaveRestore.InProgress, not just the local flag: _restoring is set by THIS
            // component's Restore, and the case that used to be open is the one where this
            // component's section is absent so its Restore is never called. The inventory
            // participant is mid-restore when it raises this, and that is visible from here.
            if (IsComplete || _restoring || RevSaveRestore.InProgress)
                return;

            if (delta.owner != Owner || delta.IsEmpty || !item)
                return;

            if (!_watched.Contains(delta.container))
                return;

            int net = NetChangeOfItem(delta);
            if (net == 0)
                return;

            Held = Mathf.Max(0, Held + net);
            TryComplete(announce: true);
        }

        /// <summary>
        /// The arithmetic: how much of this objective's item this delta actually moved.
        /// </summary>
        /// <remarks>
        /// Reading both ends of every change is what makes a merge, a swap and a sort come out at zero.
        /// Anything that looks only at <c>after</c> — or, worse, at how many slots changed — reads a
        /// tidy-up as a haul.
        /// </remarks>
        private int NetChangeOfItem(in InventoryDelta delta)
        {
            int net = 0;

            for (int i = 0; i < delta.changes.Count; i++)
            {
                InventoryDelta.Change change = delta.changes[i];

                if (change.after.def == item)
                    net += Mathf.Max(0, change.after.quantity);

                if (change.before.def == item)
                    net -= Mathf.Max(0, change.before.quantity);
            }

            return net;
        }

        /// <summary>
        /// Latches completion, then announces it. The order is deliberate.
        /// </summary>
        /// <remarks>
        /// A subscriber granting a reward writes to Inventory, which raises another delta, which
        /// arrives here before this call has returned. Setting the flag first means that re-entrant
        /// delta finds an objective that is already complete and returns immediately. Announcing first
        /// would complete it twice, and hand out two rewards.
        /// </remarks>
        private void TryComplete(bool announce)
        {
            if (IsComplete || Held < target)
                return;

            IsComplete = true;

            if (announce)
                Completed?.Invoke(this);
        }

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

            if (containers == null)
                return;

            for (int i = 0; i < containers.Length; i++)
            {
                if (string.IsNullOrWhiteSpace(containers[i]))
                    continue;

                // Canonicalised, not stored raw. ContainerId trims and lower-cases, and the id on the
                // delta is always the canonical form -- so an authored "Backpack" (the spelling the
                // framework's own docs and samples use) would never match a raw ordinal compare, and
                // the objective would silently never progress. Going through ContainerId also folds
                // "backpack" and "Backpack" into one entry, which stops Rescan counting the same
                // container twice.
                _watched.Add(new ContainerId(containers[i]).Value);
            }
        }

        // -----------------------------------------------------------------------------------------
        // Save
        // -----------------------------------------------------------------------------------------

        /// <inheritdoc />
        /// <remarks>
        /// One boolean, plus a stamp. Everything else is derivable — the containers are Inventory's to
        /// save, and it does. Writing the count here as well would create a second copy that can
        /// disagree with the first, and the first is the one the player can see.
        /// </remarks>
        public string Capture() => JsonUtility.ToJson(new Payload
        {
            kind = PayloadKind,
            complete = IsComplete,
        });

        /// <inheritdoc />
        /// <remarks>
        /// <para><b>Silent.</b> Restoring writes the flag directly and raises nothing, matching every
        /// participant in the framework: reading a save must not fire the events that state would
        /// normally fire, or a reload hands out every reward again.</para>
        ///
        /// <para><b>The stamp is not decoration.</b> A section is addressed only by a key, two
        /// participants can choose the same key, and <c>JsonUtility</c> never fails on a document it
        /// does not recognise — a missing <c>bool</c> comes back <c>false</c>, which reads as a
        /// perfectly ordinary incomplete objective. Without one field a foreign document cannot supply,
        /// somebody else's payload would silently un-complete this.</para>
        /// </remarks>
        public void Restore(string payload, int version)
        {
            if (version > Version)
                throw new InvalidOperationException(
                    $"[{nameof(GatherObjective)}] '{saveKey}' was written by version {version}; this " +
                    $"build reads {Version}. Nothing was applied.");

            if (string.IsNullOrWhiteSpace(payload))
                throw new InvalidOperationException(
                    $"[{nameof(GatherObjective)}] '{saveKey}' was handed a blank payload.");

            Payload parsed;
            try
            {
                parsed = JsonUtility.FromJson<Payload>(payload);
            }
            catch (Exception e)
            {
                throw new InvalidOperationException(
                    $"[{nameof(GatherObjective)}] '{saveKey}' could not parse its payload.", e);
            }

            if (parsed == null || !string.Equals(parsed.kind, PayloadKind, StringComparison.Ordinal))
                throw new InvalidOperationException(
                    $"[{nameof(GatherObjective)}] '{saveKey}' was handed a payload it did not write. " +
                    "Nothing was applied.");

            IsComplete = parsed.complete;

            // A load writes into live containers, so deltas are still arriving. Stop counting them
            // until the coordinator says the load is over; the total is re-derived then anyway.
            _restoring = true;
        }

        /// <summary>
        /// Re-derives the total after a load, and reports it.
        /// </summary>
        /// <remarks>
        /// <para>Runs from <c>LoadCompleted</c> rather than from <see cref="Restore"/>, and the reason
        /// is ordering: this objective's own section is restored before Inventory's, so re-deriving
        /// inside <c>Restore</c> would count the containers the player had <i>before</i> the load.
        /// <c>LoadCompleted</c> is the first moment every participant has finished.</para>
        ///
        /// <para><b>A save with no section for this objective needs more than the rescan.</b> The
        /// coordinator does not call <see cref="Restore"/> for a participant whose key is not in the
        /// file, and says nothing about it — a save older than a system is ordinary, not a fault. The
        /// rescan that follows re-derives <see cref="Held"/>, but it cannot re-derive
        /// <see cref="IsComplete"/>: that flag latches, and a rescan only ever sets it. So the flag
        /// this session earned would survive into a lineage that never earned it, and the next save
        /// would write <c>complete=true</c> into a file whose predecessor had no such thing.</para>
        ///
        /// <para>The report is what answers it. An outcome carrying this participant's key means
        /// <see cref="Restore"/> ran and the flag is already this save's; no such outcome means the
        /// section was absent, and the latch belongs to a game that is no longer loaded. It is cleared
        /// before the rescan, so an objective the restored containers still satisfy re-latches on its
        /// own — silently, which is the same rule activation follows.</para>
        ///
        /// <para><b>Checked against <c>FatalError</c> rather than success, deliberately.</b> A fatal
        /// load applied nothing to anybody, so clearing would discard a flag that is still correct.
        /// Another participant failing its own section says nothing about whether ours was in the
        /// file. This is the discipline the "One store, many facts" recipe establishes for exactly
        /// this shape of session state.</para>
        ///
        /// <para>It is policy, and it is the only policy here: a game where an older save should keep
        /// an objective already completed can delete the clearing and keep the rescan.</para>
        /// </remarks>
        private void OnLoadCompleted(string slot, RevSaveReport report)
        {
            _restoring = false;

            if (!SectionWasInThisSave(report))
                IsComplete = false;

            Rescan();
            Reloaded?.Invoke(this);
        }

        /// <summary>
        /// Whether the load that just finished carried a section for this participant.
        /// </summary>
        /// <remarks>
        /// Answers <c>true</c> when it cannot tell — a null report, or a fatal load that applied
        /// nothing — because the cost of being wrong is asymmetric. Wrongly believing the section was
        /// there leaves a stale flag that the player earned once; wrongly believing it was absent
        /// throws away a completion that was genuinely saved.
        /// </remarks>
        private bool SectionWasInThisSave(RevSaveReport report)
        {
            if (report == null || !string.IsNullOrEmpty(report.FatalError))
                return true;

            var outcomes = report.Outcomes;
            for (int i = 0; i < outcomes.Count; i++)
                if (string.Equals(outcomes[i].Key, Key, StringComparison.Ordinal))
                    return true;

            return false;
        }

        private const string PayloadKind = "gather-objective";

        [Serializable]
        private sealed class Payload
        {
            public string kind;
            public bool complete;
        }
    }
}

Wiring it up

  1. Put GatherObjective on the same object as your RevSaveManager, or anywhere that outlives the scenes it watches.
  2. Set the item, the target, and the containers that count. Give the save key your own prefix.
  3. Register nothing — it joins the coordinator itself.
  4. Connect the reward where your game already grants things:
objective.Completed += o =>
{
    Hud.Fanfare("Herbs gathered!");
    Rewards.GiveQuestReward("herbs");   // yours -- see below
};

objective.Reloaded += o => Tracker.Redraw(o.Held, o.Target, o.IsComplete);
  1. Call ResetForNewGame() when a run begins.

What it deliberately does not do

It does not deliver the reward. This is the most important line on the page. Completed fires and the recipe stops. Where a reward comes from, whether it can be refused, what happens when the bag is full, and whether a reload grants it again are decisions with real consequences — and a reward that cannot be delivered is a solved problem in one direction and an open one in another. Baking any of it into something called an objective tracker would make the tracker wrong for every game that answers differently.

If you own the Complete package, IRewardService.Grant(owner, ledger, store, PriceBundle, reason, sourceId) is the route, with the bundle built by you. If you do not, AddItem is the small version. Either way it belongs in your handler, not in here.

It is not a quest. No stages, no branching, no prerequisites, no optional steps, no expiry. Those are a content model, and the moment this file grew an IObjective with two implementations it would have stopped being a recipe and started being a worse version of the thing you already know how to write.

It counts one item. Not a set, not a category, not "any three of these". A second objective is a second component with its own save key, and that is the correct amount of sharing.

It does not enumerate containers. There is no "watch everything" option, because the service answers per (owner, container) and cannot list them. Naming the set is the supported shape, and it is also the thing that makes the cross-container rule meaningful.

It does not persist the count. Only the latch. See above.