Skip to content

A pouch that arrives whole or not at all

A player uses a loot pouch holding five ingots with three slots free. What should happen is obvious to them and not to the code: either they get all five, or they keep the pouch and come back when there is room. What they must never get is three ingots and a pouch that still works.

Recipe

Systems required: Inventory. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists — a ScriptableObject asset you create and assign to an item's useEffects. No scene, no prefab. Public API only. It assumes: the target has a CharacterInventory, and the item that carries this effect is marked usable. 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

ItemUseSystem consumes the item when an effect reports delivery and leaves it alone when the effect refuses. IUseEffectReportsDelivery is explicit about what counts:

A partial delivery counts as refused unless the effect documents otherwise, since the caller is deciding whether to consume the item.

Follow that instruction with an add that places what fits, and you have not written a refusal. You have written a duplication bug.

Partial delivery plus an honest refusal is a duplication exploit

The bag keeps the three ingots that fitted. The effect reports refused, because two did not. The pouch is therefore not consumed — and the player uses it again, and again, for as many ingots as they like.

The other horn is no better: report true after a partial add and the pouch is consumed while the two that did not fit are silently destroyed.

Both come from the same missing property. Only an atomic add avoids choosing between them.

The atomic add is on the component, and only there

// All of it or none of it.
inventory.TryAddAllResult(stack);

CharacterInventory.TryAddAllResult is the only all-or-nothing add on the public surface. Both service-level adds are partial by construction and hand back what they could not place:

SceneInventoryService.AddMax(owner, stack, out var leftover, containerId);   // partial
IInventoryService.AddMax(owner, stack, out var leftover, containerId);       // partial

So the instinct is the wrong one here. Reaching for the service is what every other piece of inventory code does, and it is the one route that cannot be made honest in an effect whose caller is deciding whether to consume the item. Nothing in the framework warns about this; the shapes of the two methods are the whole of the warning.

An empty stack reports success, not failure

InventoryContainer.TryAddAllResult answers Ok("Nothing to add.") when the stack is empty — and a stack is empty when def is null or quantity <= 0.

So a pouch with no item assigned, or a quantity of zero, would report delivered and be consumed for handing over nothing. That is what the first guard in this recipe prevents, and it is why the guard is not decoration.

Two things it inherits rather than teaches

AllowsNullDamageable must be true. A pouch has nothing to do with damage, but ItemUseSystem skips any effect requiring an IDamageable when the target has none, and skipped effects do not count as applied — so the use fails and the pouch looks inert in exactly the projects with no health components. PricedPickup covers this and the delivery-reporting pattern itself, on the Pickups side; this page assumes both rather than repeating them.

The effect is handed the target, not the user. ItemUseSystem resolves effectTargetOverride ?? defaultEffectTarget ?? ownerWithInventory, so by default the target is whoever used the item. Set defaultEffectTarget — a crosshair actor, a companion — and the same pouch fills that actor's bag instead, silently. No argument identifies the user, so a pouch that must always fill the user's bag cannot be written against this seam alone.

What it does not do

  • One item per pouch, and that is the API rather than a simplification. The atomic add takes a single stack, so two different items mean two adds, and the second can fail after the first succeeded — partial delivery again, with nothing to roll back with.
  • It is not idempotent. Each use grants again, which is what a pouch should do. Worth stating because a half-applied use is not something this seam can express, so there is nothing for save/restore to reconcile.
  • It does not create the inventory. No CharacterInventory on the target means a refusal, which is the safe answer: the pouch survives.

A second use effect on the same item cancels the refusal

ItemUseSystem counts the effects that delivered and consumes the item when any of them did:

if (delivered) applied++;
...
if (applied == 0)
    return InvOpResult.Fail(InvOpCode.UnknownError, "No effects applied.");

So the refusal above only survives while this is the only delivering effect on the item. Put a heal, a buff or a plain Apply-only effect in the same useEffects list — anything that reports delivery, and every non-reporting effect is assumed to have delivered — and a full bag consumes the pouch with its contents undelivered. There is no seam that lets one effect veto the others.

Keep a pouch's useEffects to the pouch. If the item must also do something else, split it into two items, or write the other behaviour into a copy of this class where one atomic add can cover both.

Drop it in

WholePouch.cs
using UnityEngine;

using RevGaming.RevFramework.Core.Abstractions.Combat;
using RevGaming.RevFramework.Inventory;
using RevGaming.RevFramework.Inventory.Data;
using RevGaming.RevFramework.Inventory.Use;

namespace RevGaming.RevFramework.Cookbook.WholePouch
{
    /// <summary>
    /// A pouch that hands over its whole contents or none of them, and is not spent when the bag
    /// refuses it.
    /// </summary>
    /// <remarks>
    /// <para><b>The refusal you report is only honest if the delivery was atomic.</b>
    /// <c>ItemUseSystem</c> consumes the item when an effect reports delivery and leaves it alone when
    /// the effect refuses — and <see cref="IUseEffectReportsDelivery"/> is explicit that <i>a partial
    /// delivery counts as refused</i>. Put those two together with an add that delivers what fits and
    /// the result is not a refusal, it is a duplication bug: the bag keeps what fitted, the effect
    /// reports refused, the pouch is never consumed, and the player uses it again.</para>
    ///
    /// <para><b>Which is why this goes through <see cref="CharacterInventory.TryAddAllResult"/>, and
    /// why that matters more than it looks.</b> It is the only all-or-nothing add on the public
    /// surface, and it is on the component. The service-level API — <c>SceneInventoryService.AddMax</c>
    /// and <c>IInventoryService.AddMax</c> — is partial by construction, returning the leftover it
    /// could not place. So the natural instinct, reaching for the service because that is what
    /// everything else resolves, is the one that cannot be made honest here. Nothing in the framework
    /// says so; the shapes of the two methods are the whole warning.</para>
    ///
    /// <para><b>The other horn, for completeness.</b> Reporting <c>true</c> after a partial add
    /// consumes the pouch and silently destroys whatever did not fit. Both horns come from the same
    /// missing property, and only an atomic add avoids choosing between them.</para>
    ///
    /// <para><b><see cref="AllowsNullDamageable"/> must be <c>true</c> or this never runs.</b> A pouch
    /// has nothing to do with damage, but <c>ItemUseSystem</c> skips any effect that requires an
    /// <see cref="IDamageable"/> when the target has none — and skipped effects do not count as
    /// applied, so the use fails with "No effects applied" and the pouch looks inert in exactly the
    /// projects that have no health components. <c>PricedPickup</c> carries the same property for the
    /// same reason on the Pickups side; the delivery-reporting pattern itself is that recipe's, and
    /// this page assumes it rather than re-teaching it.</para>
    ///
    /// <para><b>The effect is handed the target, not the user.</b> <c>ItemUseSystem</c> resolves
    /// <c>effectTargetOverride ?? defaultEffectTarget ?? ownerWithInventory</c>, so by default the
    /// target is whoever used the item and this fills their bag. Set <c>defaultEffectTarget</c> on the
    /// system — a crosshair actor, a companion — and the same pouch fills <i>that</i> actor's bag
    /// instead, silently. There is no argument identifying the user, so a pouch that must always fill
    /// the user's bag cannot be written against this seam alone.</para>
    ///
    /// <para>Limits, stated rather than hidden. <b>One item per pouch, and that is a constraint of the
    /// API rather than a simplification:</b> the atomic add takes a single stack, so granting two
    /// different items means two adds, and the second can fail after the first succeeded — partial
    /// delivery again, with no way to roll back. And nothing here is idempotent: each use grants
    /// again, which is what a pouch should do and is worth saying because save/restore of a
    /// half-applied use is not a thing this seam can express.</para>
    /// </remarks>
    [CreateAssetMenu(
        fileName = "WholePouch",
        menuName = "RevFramework/Cookbook/Whole Pouch")]
    public sealed class WholePouch : ScriptableObject, IUseEffect, IUseEffectReportsDelivery
    {
        [Tooltip("What the pouch contains. Without this the pouch refuses rather than succeeding at nothing.")]
        [SerializeField] private ItemDefinition item;

        [Tooltip("How many. The whole amount arrives or none of it does.")]
        [SerializeField, Min(1)] private int quantity = 1;

        /// <inheritdoc />
        /// <remarks>
        /// True, and it is load-bearing: a pouch is not a weapon, and an effect that demands a
        /// damageable is skipped outright on a target that has none.
        /// </remarks>
        public bool AllowsNullDamageable => true;

        /// <inheritdoc />
        /// <remarks>Delegates, so the reporting and non-reporting entry points cannot drift.</remarks>
        public void Apply(IDamageable dmg, GameObject target) => TryApply(dmg, target);

        /// <summary>
        /// Adds the whole contents to the target's inventory, or nothing at all, and reports which.
        /// </summary>
        /// <param name="dmg">Unused. A pouch has no damage payload.</param>
        /// <param name="target">The actor whose bag is filled — see the remarks on target versus user.</param>
        /// <returns><c>true</c> only when every unit arrived, which is what lets the caller consume the pouch.</returns>
        public bool TryApply(IDamageable dmg, GameObject target)
        {
            // Refusing on a misconfigured asset rather than reporting success is the difference
            // between a pouch that does nothing and a pouch that is eaten for doing nothing.
            if (!item || quantity <= 0) return false;

            // Fake-null, not a plain null check: a destroyed target is a live reference here.
            if (!target) return false;

            if (!target.TryGetComponent(out CharacterInventory inventory)) return false;

            // The one all-or-nothing add on the public surface. AddMax would place what fits and hand
            // back the remainder, and there is no version of "report the remainder" that is not either
            // a duplication bug or a silent loss.
            var stack = new ItemStack { def = item, quantity = quantity };

            return inventory.TryAddAllResult(stack).Success;
        }
    }
}

Wiring it up

  1. Create the asset: Assets ▸ Create ▸ RevFramework ▸ Cookbook ▸ Whole Pouch.
  2. Set item and quantity — what the pouch hands over, and how much of it.
  3. On the ItemDefinition for the pouch itself, tick usable and add this asset to useEffects. UseResult refuses outright on !def.usable, so an untouched flag makes the pouch inert before any of this runs.
  4. Give the receiving actor a CharacterInventory. The atomic add is on the component, so a target with only a service-level inventory is a refusal — the safe direction, but it looks like a broken pouch.
  5. Leave defaultEffectTarget on the ItemUseSystem empty unless you mean it. It is what decides whose bag fills, and it is resolved as effectTargetOverride ?? defaultEffectTarget ?? ownerWithInventory.