Skip to content

A shop that hands over the goods when the bag is full

The purchase lands on the floor instead of the sale failing — and it is the same shop, with a different store.

Recipe

Systems required: Economy, Pickups, Inventory — which means the Complete (All Systems) package, since no smaller one carries Economy and Pickups together. Package: Complete only — the systems above ship in different packages, so no single-system package can run this. Shape: one class you drop into a project that already exists. No scene, no prefab authoring beyond a pickup you almost certainly have, no setup ritual. Public API only. 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

IShopService.Buy and Sell, ICraftingService.Craft and IRewardService.Grant all take an IItemStore — three of them deliver through it, and Sell removes through it. Economy owns no store, resolves no store, and the interface never mentions a container.

So "where the goods go" is whatever you hand it. A shop that delivers to a stash, a guild bank, a mule, a mailbox or the floor is not a new shop — it is the same shop with a different store. Four methods, no base class, no registration.

Third time, so it is now a rule

The status-priced shop found the price was an argument. The blood ledger found the money was an argument. This one finds the destination is too.

Before asking for a seam, check whether the thing you want to vary is already being passed in. Three of this cookbook's strongest "there is no hook for this" instincts have now dissolved on that one question.

The only IItemStore you can instantiate is internal

InventoryItemStore lives inside the Economy assembly and is internal. You get an instance of it from EconomyInventoryBootstrap.BuildForPlayer — and that instance is what you pass to this component as the inner store.

Readable source for the shape does ship, though. FailingItemStore, nested in EconomyWithInventoryPanel under Integrations/CrossSystem/Economy, is a four-member decorator over an inner store — the same shape as this recipe, forwarding the HasSpaceFor this one deliberately does not. It can also be told to fail a delivery part-way, which makes it the harness for the rollback case below.

It decorates, it does not replace

Purchases go in the bag when there is room, exactly as before. Only a NoSpace refusal reaches the floor, and everything else — selling, item costs, removal — is forwarded to the inner store untouched.

A store that always spilled would be a worse bag, not a better shop.

Only NoSpace becomes a drop, and that distinction is load-bearing

The bag refuses for more reasons than a full bag: a denied inventory authority, a missing container, an item id the resolver cannot map. Every one of those would refuse the collected drop just as flatly — GiveItemEffect goes back through the same inventory service the bag did — and TriggerPickup correctly declines to consume a pickup whose payload was refused.

Convert those into drops and the player is charged for a pickup that can never be collected and never disappears. So Add branches on the code, not on success.

HasSpaceFor answers true, always, and that is the point

Economy's preflight exists so UX can grey out a button before the player commits. With this store in place the honest answer is that the purchase cannot fail for space — so the button stays live and the sale goes through.

A decorator that dutifully forwarded the preflight would refuse a sale it was perfectly capable of completing. The preflight is not a formality to pass along; it is a claim about this store.

The price is that the preflight now answers about space and nothing else. Buy runs it before any money moves, so the bag's other refusals — no container, an unresolvable item — used to cost the player nothing. They are now discovered by Add, after the charge, where all the framework can do is refund; and EcoRollbackUtil says in its own remarks that a refund can be refused.

The sale completes the moment the goods hit the floor

Add reports success, so the shop charges and moves on. If the player walks away from the pickup, they have paid for something they will never carry.

That is a deliberate trade, and the alternative is the one you already have: refusing the sale outright. Give the drop a despawn timer, a marker, or a shopkeeper line if losing it would sting.

Three further ways a paid-for drop is lost

Anyone can pick it up. The bag was built for one player; a drop is not. It goes to whoever walks into it, passes the prefab's layer and tag filters and owns the container named on this component — so in co-op the buyer's goods can end up with their partner. TriggerPickup already resolves an IPickupAuthority on every accepted enter, which is where you gate that.

A save does not remember it. Save ships participants for Currency and Inventory and none for Pickups. Save between the sale and the collection and the charge comes back while the goods do not — no event, no log. If your game autosaves, prefer refusing the sale, or give drops a short despawn so the window stays small and visible.

Destroying this component kills every outstanding drop. Its OnDestroy releases the effect each live pickup is holding, and a pickup without an effect does nothing when walked into. Put the component on something that outlives its own drops — the shopkeeper, a manager — not on a shop UI object built per visit.

One effect asset per drop, and sharing one would be a bug

The spawned pickup needs a GiveItemEffect carrying its own item — and a PickupEffect is a ScriptableObject. A single cached instance mutated per spawn would leave two pickups on the floor both handing out whichever item was dropped last.

Which makes their lifetime your problem

An unreferenced ScriptableObject created this way carries HideFlags.DontSave, which includes DontUnloadUnusedAsset — so it is never garbage collected, UnloadUnusedAssets skips it, and a scene load does not reclaim it. Only an explicit Destroy does, so a shop used all game leaks one object per overflow for the lifetime of the process.

They are pruned as their pickups die and cleared on destroy. Destroy also refuses to run outside play mode and logs an error instead — which would leak the very object the cleanup exists to release — so the release branches on Application.isPlaying.

Why this reuses GiveItemEffect rather than delivering the item itself

Writing the delivery by hand would have been a second, drifting copy of something that already ships and already handles cases this does not. It pulls Inventory into the declaration, and that costs you nothing: Pickups only ever ships in a package that already contains Inventory.

The real price is a prerequisite. The shipped effect resolves the inventory service from the collector at the moment the drop is walked into — a SceneInventoryService in the scene — not the IInventoryService you handed the bootstrap. Hand the bootstrap a service of your own with no SceneInventoryService present and the bag half of this store works while the floor half silently does not.

Assigning the effect after Instantiate is safe here

TriggerPickup reads its effect when something walks into it, not in Awake. The timing trap that catches payload-carrying spawners — where the trigger relay resolves its receiver during Awake, so a component added afterwards is never found — does not apply, because the receiver is the TriggerPickup itself and it is already on the prefab.

Drop it in

FloorStore.cs
using System;
using System.Collections.Generic;

using RevGaming.RevFramework.Economy;
using RevGaming.RevFramework.Economy.Abstractions;

using RevGaming.RevFramework.Pickups.Core;
using RevGaming.RevFramework.Pickups.Effects;
using RevGaming.RevFramework.Pickups.UnityIntegration;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.FloorStore
{
    /// <summary>
    /// A shop that hands over the goods even when the buyer's bag is full — by putting the overflow
    /// on the floor instead of failing the sale.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
    /// <b>Economy</b>, <b>Pickups</b>, <b>Inventory</b> — which means the <b>Complete (All Systems)</b>
    /// package, since no smaller one carries Economy and Pickups together. Public API only.</para>
    ///
    /// <para><b>The reveal is that the item store is an argument.</b> <c>IShopService.Buy</c> and
    /// <c>Sell</c>, <c>ICraftingService.Craft</c> and <c>IRewardService.Grant</c> all take an
    /// <see cref="IItemStore"/>. Economy owns no store, resolves no store, and the interface never
    /// mentions a container — so "where the goods go" is whatever you hand it. A shop that delivers to
    /// a stash, a guild bank, a mule, a mailbox or the floor is not a new shop; it is the same shop
    /// with a different store. (The bootstrap that builds the inner store <i>does</i> take a container
    /// name; the interface it hands back does not. See <see cref="containerKey"/>.)</para>
    ///
    /// <para><b>This is a decorator, not a replacement.</b> Purchases go in the bag when there is room.
    /// <b>Only a <c>NoSpace</c> refusal reaches the floor</b> — every other refusal the bag can give
    /// (no authority, no container, an unresolvable item) still fails the sale, because a refusal that
    /// would refuse the collected drop as well is not an overflow. Selling, item costs and removal are
    /// forwarded untouched. A store that always spilled would be a worse bag, not a better shop.</para>
    ///
    /// <para><b><see cref="HasSpaceFor"/> answers true, always, and that is the point.</b> There is
    /// always room on the floor, so the honest preflight answer is that the purchase cannot fail for
    /// space — the button stays live and the sale goes through. <b>The price is that the preflight now
    /// answers about space and nothing else:</b> the bag's other refusals used to be caught there,
    /// before any money moved, and are now discovered by <see cref="Add"/> after the charge, where the
    /// framework can only refund — and <c>EcoRollbackUtil</c>'s own remarks say a refund can be
    /// refused.</para>
    ///
    /// <para><b>The sale completes the moment the goods hit the floor.</b> <c>Add</c> reports success,
    /// so the shop charges and moves on; a player who walks away from the pickup has paid for
    /// something they will not carry. Give the drop a despawn timer or a marker if that would
    /// sting.</para>
    ///
    /// <para><b>Three further ways a paid-for drop is lost, none of them obvious.</b> A drop is
    /// unowned — handed to whoever walks into it, passes the prefab's filters and owns the named
    /// container, so in co-op the buyer's goods can end up with their partner (<c>TriggerPickup</c>
    /// resolves an <c>IPickupAuthority</c> on every accepted enter, which is where you gate it).
    /// Nothing persists a world pickup: Save ships participants for Currency and Inventory and none
    /// for Pickups, so a save between the sale and the collection keeps the charge and loses the
    /// goods. And outstanding drops go inert when this store is destroyed (<see cref="OnDestroy"/>),
    /// so it belongs on something at least as long-lived as its drops — not a shop UI object built per
    /// visit.</para>
    ///
    /// <para><b>One effect asset per drop, and sharing one would be a bug.</b> The spawned pickup needs
    /// a <see cref="GiveItemEffect"/> carrying <i>its own</i> item, and a <see cref="PickupEffect"/> is
    /// a <see cref="ScriptableObject"/> — a single cached instance mutated per spawn would leave two
    /// pickups both handing out whichever item was dropped last. Created per drop, their lifetime is
    /// this class's problem: <c>HideFlags.DontSave</c> includes <c>DontUnloadUnusedAsset</c>, so
    /// nothing collects them — not the GC, not <c>UnloadUnusedAssets</c>, not a scene load. Only an
    /// explicit <c>Destroy</c> does. They are pruned as their pickups die, and cleared on
    /// destroy.</para>
    ///
    /// <para><b>Reusing <see cref="GiveItemEffect"/> carries one prerequisite.</b> Writing the delivery
    /// by hand would be a second, drifting copy of something that already ships. The price is that the
    /// shipped effect resolves the inventory service from the <i>collector</i> when the drop is walked
    /// into — a <c>SceneInventoryService</c> in the scene — not the <c>IInventoryService</c> you handed
    /// the bootstrap. Hand the bootstrap a service of your own with no <c>SceneInventoryService</c>
    /// present and the bag half works while the floor half silently does not.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    public sealed class FloorStore : MonoBehaviour, IItemStore
    {
        [Header("Drop")]
        [Tooltip("Pickup prefab used for overflow. Must carry a TriggerPickup, a TriggerRelay3D (or " +
                 "TriggerRelay2D) and a trigger collider — the relay is what forwards the trigger — and " +
                 "its root must be active. Only the effect is assigned per drop; everything else on the " +
                 "prefab is still yours.")]
        [SerializeField] private GameObject pickupPrefab;

        [Tooltip("Where overflow lands. Leave empty to drop at this object's position.")]
        [SerializeField] private Transform dropPoint;

        [Tooltip("Radius drops are scattered within, so several never stack into what looks like one " +
                 "pickup that the buyer collects once and walks away from.")]
        [SerializeField, Min(0f)] private float scatterRadius = 0.75f;

        [Tooltip("Scatter across the screen plane (XY) rather than the ground plane (XZ). Turn this on " +
                 "for a 2D game, where an XZ scatter moves every drop along the camera axis and piles " +
                 "them onto one screen position.")]
        [SerializeField] private bool scatterOnScreenPlane;

        [Header("Delivery")]
        [Tooltip("Container a collected drop is granted into. Match it to the container you passed the " +
                 "bootstrap, or a spilled item comes back into a different one — and a container the " +
                 "collector does not own refuses the drop outright.")]
        [SerializeField] private string containerKey = "Backpack";

        private IItemStore _inner;
        private Func<string, UnityEngine.Object> _resolveItem;

        // Effects created for live drops, paired with the pickup that owns each one. Pruned rather than
        // released on collect: nothing tells this class a pickup was taken, and the effect has to outlive
        // the Add call that created it.
        private readonly List<SpilledDrop> _spawned = new List<SpilledDrop>();

        /// <summary>
        /// Gives this store the bag it decorates and the resolver that turns an item id into the asset a
        /// pickup can hand over.
        /// </summary>
        /// <param name="inner">Store consulted first. Get one from
        /// <c>EconomyInventoryBootstrap.BuildForPlayer</c>.</param>
        /// <param name="resolveItem">Maps an <see cref="ItemLine"/> id to the item definition asset. The
        /// same job the bootstrap already asks you for, so you can pass the resolver you have.</param>
        /// <remarks>
        /// An interface and a delegate, so neither can come from the Inspector. Call this from wherever
        /// you already build your Economy services.
        /// </remarks>
        public void Bind(IItemStore inner, Func<string, UnityEngine.Object> resolveItem)
        {
            _inner = inner;
            _resolveItem = resolveItem;
        }

        /// <summary>
        /// Whether this store could take the item. True while this component is alive — there is always
        /// room on the floor.
        /// </summary>
        /// <remarks>
        /// Deliberately not forwarded. This is a UX preflight, and forwarding it would grey out a
        /// purchase that <see cref="Add"/> would have completed. The destroyed check is not ceremony:
        /// Economy guards its store with <c>store == null</c> on an interface-typed reference, which is
        /// a plain reference comparison, so Unity's destroyed-object null never reaches it. A
        /// <see cref="MonoBehaviour"/> handed out as a framework interface has to refuse for itself.
        /// </remarks>
        public bool HasSpaceFor(in ItemLine item) => this;

        /// <inheritdoc />
        /// <remarks>
        /// Forwarded unchanged, past the same destroyed check as <see cref="HasSpaceFor"/>. Items on the
        /// floor belong to nobody, so they cannot be spent, sold, or handed over as part of a price —
        /// only what is actually in the bag counts.
        /// </remarks>
        public bool CanRemove(in ItemLine item) => this && _inner != null && _inner.CanRemove(item);

        /// <summary>
        /// Delivers the item to the bag, or to the floor when the bag has no room for it.
        /// </summary>
        /// <returns>
        /// <see cref="EcoOpResult.Ok"/> when the item reached the buyer or the floor. The inner store's
        /// own failure otherwise — its code is preserved rather than replaced, so a caller reading
        /// <c>NoSpace</c> still sees <c>NoSpace</c>.
        /// </returns>
        /// <remarks>
        /// Only <c>NoSpace</c> is spilled. Every other refusal the bag can give would refuse the
        /// collected drop just as flatly — a denied authority denies the pickup too, and an item the
        /// resolver cannot map cannot be handed over by either route — so converting one into a drop
        /// would charge the player for goods nothing can deliver.
        /// </remarks>
        public EcoOpResult Add(in ItemLine item, string reason = null)
        {
            if (!this)
                return EcoOpResult.ServiceMissing("FloorStore has been destroyed.");

            if (_inner == null)
                return EcoOpResult.ServiceMissing("FloorStore has no inner store. Call Bind first.");

            if (!item.IsValid)
                return EcoOpResult.InvalidArgs("Item line has no id, or a quantity of zero.");

            EcoOpResult delivered = _inner.Add(item, reason);
            if (delivered.IsOk || delivered.Code != EcoOpCode.NoSpace)
                return delivered;

            // The inner refusal is returned unchanged when the drop cannot be made, so a caller reading
            // the code gets the real reason rather than "no prefab" -- the buyer's problem is still the
            // full bag.
            return TrySpill(item) ? EcoOpResult.Ok() : delivered;
        }

        /// <inheritdoc />
        /// <remarks>
        /// Forwarded first, and it never takes goods off the ground for a sale: <see cref="CanRemove"/>
        /// is forwarded too, so Economy's preflight refuses a sale the bag cannot cover before this runs.
        /// What the fallback is for is compensation. Economy expresses every rollback as a
        /// <c>Remove</c> of a line it already added, so a spilled line that could not be taken back would
        /// leave a failed multi-line purchase refunded in full with one item lying on the floor for free.
        /// An uncollected drop of that exact line is therefore reclaimed and the pickup destroyed. A drop
        /// the buyer already walked off with is gone and stays gone.
        /// </remarks>
        public EcoOpResult Remove(in ItemLine item, string reason = null)
        {
            if (!this)
                return EcoOpResult.ServiceMissing("FloorStore has been destroyed.");

            if (_inner == null)
                return EcoOpResult.ServiceMissing("FloorStore has no inner store. Call Bind first.");

            EcoOpResult removed = _inner.Remove(item, reason);
            if (removed.IsOk)
                return removed;

            return TryReclaim(item) ? EcoOpResult.Ok() : removed;
        }

        /// <summary>
        /// Puts one item line into the world as a pickup.
        /// </summary>
        private bool TrySpill(in ItemLine item)
        {
            // Cheapest first: none of the work below is worth doing if the drop cannot be configured.
            if (!pickupPrefab || _resolveItem == null)
                return false;

            UnityEngine.Object definition = _resolveItem(item.Guid);
            if (!definition)
                return false;

            PruneSpawned();

            Transform origin = dropPoint ? dropPoint : transform;
            GameObject instance = Instantiate(pickupPrefab, Scatter(origin.position), pickupPrefab.transform.rotation);

            // Checked on the instance rather than the prefab, because a prefab is free to be authored
            // without one and an instance is free to have lost one in Awake. An inactive root is checked
            // in the same breath: GetComponent finds the pickup on it either way, but no Awake ran, so
            // the relay never resolved its receiver and the drop would sit there forever having been
            // paid for. Neither case leaves anything behind -- the instance goes and the caller still
            // gets the bag's own refusal.
            if (!instance.TryGetComponent(out TriggerPickup pickup) || !instance.activeInHierarchy)
            {
                DestroyObject(instance);
                return false;
            }

            GiveItemEffect effect = ScriptableObject.CreateInstance<GiveItemEffect>();
            effect.itemDefinition = definition;
            effect.quantity = item.Quantity;
            effect.containerKey = containerKey;

            // Assigned after Instantiate, which is safe: TriggerPickup reads this field when something
            // walks into it, not in Awake. The timing trap that catches payload-carrying spawners does not
            // apply here, because the trigger relay's receiver is the pickup itself and it is on the prefab.
            pickup.effect = effect;

            // Not left to the prefab. A reusable pickup -- a pad, a refill station -- is an ordinary
            // thing to have lying around, and one authored with destroyOnUse off would hand the buyer
            // this item again on every re-entry.
            pickup.destroyOnUse = true;

            _spawned.Add(new SpilledDrop(item, pickup, effect));
            return true;
        }

        /// <summary>
        /// Takes one still-uncollected drop of this exact line back off the floor.
        /// </summary>
        /// <remarks>
        /// Exact line only: a drop hands over the quantity it was created with, and splitting one would
        /// mean spawning a replacement mid-rollback. Compensation always asks for the line it was given,
        /// so an exact match is the case that matters.
        /// </remarks>
        private bool TryReclaim(in ItemLine item)
        {
            for (int i = _spawned.Count - 1; i >= 0; i--)
            {
                SpilledDrop drop = _spawned[i];

                // A dead pickup is one the buyer collected. Nothing to reclaim, and PruneSpawned will
                // release its effect on the next spill.
                if (!drop.Pickup)
                    continue;

                if (drop.Line.Quantity != item.Quantity ||
                    !string.Equals(drop.Line.Guid, item.Guid, StringComparison.Ordinal))
                    continue;

                // Cleared before the destroy, which in play mode only takes effect at the end of the
                // frame: the drop has to stop delivering the moment it is reclaimed, not a frame later.
                drop.Pickup.effect = null;

                DestroyObject(drop.Pickup.gameObject);
                DestroyObject(drop.Effect);
                _spawned.RemoveAt(i);
                return true;
            }

            return false;
        }

        /// <summary>
        /// Destroys the effects whose pickups are gone.
        /// </summary>
        /// <remarks>
        /// Runs on spill rather than per frame: the list only grows when something is dropped, so that is
        /// the only moment it can need trimming, and a shop nobody is buying from costs nothing.
        /// </remarks>
        private void PruneSpawned()
        {
            for (int i = _spawned.Count - 1; i >= 0; i--)
            {
                if (_spawned[i].Pickup)
                    continue;

                DestroyObject(_spawned[i].Effect);
                _spawned.RemoveAt(i);
            }
        }

        /// <summary>
        /// Releases the effects of every live drop — which also ends those drops.
        /// </summary>
        /// <remarks>
        /// Read this as a lifetime rule rather than as cleanup. The effects have to go or the leak has
        /// only moved, and a pickup without its effect does nothing when walked into, so every
        /// outstanding drop dies with this store even though the pickups themselves are left standing in
        /// the world. They were paid for, so put this component on something that outlives its own drops.
        /// The pickups are left rather than destroyed on purpose: deleting them would be this class
        /// deciding to bin the buyer's goods, and leaving them lets a despawn timer or a scene reload own
        /// that call instead.
        /// </remarks>
        private void OnDestroy()
        {
            for (int i = 0; i < _spawned.Count; i++)
            {
                if (_spawned[i].Pickup)
                    _spawned[i].Pickup.effect = null;

                DestroyObject(_spawned[i].Effect);
            }

            _spawned.Clear();
        }

        /// <summary>
        /// Releases an object this class created, whichever mode we are running in.
        /// </summary>
        /// <remarks>
        /// <c>Destroy</c> refuses to run outside play mode and logs an error instead, which would leak
        /// the very object this exists to release — or, for a half-configured drop, leave the instance
        /// dirtying the scene. Edit-mode callers are not hypothetical: a store bound from an editor tool
        /// goes through exactly this path.
        /// </remarks>
        private static void DestroyObject(UnityEngine.Object obj)
        {
            if (!obj)
                return;

            if (Application.isPlaying)
                Destroy(obj);
            else
                DestroyImmediate(obj);
        }

        /// <summary>
        /// Offsets a drop within <see cref="scatterRadius"/>, on the configured plane.
        /// </summary>
        private Vector3 Scatter(Vector3 position)
        {
            if (scatterRadius <= 0f)
                return position;

            Vector2 offset = UnityEngine.Random.insideUnitCircle * scatterRadius;

            return scatterOnScreenPlane
                ? position + new Vector3(offset.x, offset.y, 0f)
                : position + new Vector3(offset.x, 0f, offset.y);
        }

        /// <summary>
        /// A pickup on the floor, the effect created for it, and the line it was spilled for.
        /// </summary>
        /// <remarks>
        /// Pairing the pickup with its effect is the whole point: the effect has no owner otherwise, and
        /// Unity's lifetime rules do not connect a <see cref="ScriptableObject"/> to the
        /// <see cref="GameObject"/> referencing it. The line rides along so a rollback can find the drop
        /// it needs to take back.
        /// </remarks>
        private readonly struct SpilledDrop
        {
            public readonly ItemLine Line;
            public readonly TriggerPickup Pickup;
            public readonly GiveItemEffect Effect;

            public SpilledDrop(in ItemLine line, TriggerPickup pickup, GiveItemEffect effect)
            {
                Line = line;
                Pickup = pickup;
                Effect = effect;
            }
        }
    }
}

Wiring it up

  1. Put the component on the shopkeeper, or anywhere your shop code can reach.
  2. Assign a pickup prefab carrying a TriggerPickup, a TriggerRelay3D (or TriggerRelay2D) and a trigger collider, with its root active. The relay is what forwards the trigger, and it resolves the receiver in Awake — so a prefab carrying only the TriggerPickup is inert, and an inactive root never runs that Awake at all. Anything the shipped pickup tooling built for you already has all three. Only the effect is replaced per drop; everything else on the prefab is still yours.
  3. Set the delivery container to the one you pass EconomyInventoryBootstrap.BuildForPlayer. They are configured in two unconnected places and nothing checks that they agree, so a spilled item otherwise comes back into a different container than the one it was bought for — and a container the collector does not own refuses the drop outright.
  4. Call Bind with the inner store from EconomyInventoryBootstrap.BuildForPlayer and the same item resolver you already pass that bootstrap.
  5. Pass this component to IShopService.Buy as the store, in place of the one the bootstrap returned.

What it deliberately does not do

It does not spill on removal. A sale takes goods out of the bag, and nothing is ever put on the floor to satisfy a removal.

It does take a drop back for a rollback, and only for a rollback. Economy expresses every compensation as a Remove of a line it already added, so a spilled line that could not be taken back would leave a failed multi-line purchase refunded in full with one item lying on the floor for free — and repeatable, since only successful buys are recorded against the request id. Remove therefore reclaims a still-uncollected drop of that exact line when the bag refuses. A drop the buyer already walked off with is gone and stays gone, and an ordinary sale never reaches this path: CanRemove is forwarded, so Economy's preflight has already refused anything the bag does not hold.

It does not report the floor as ownership. CanRemove is forwarded unchanged, so items lying in the world cannot be spent, sold, or handed over as part of a price. Only what is in the bag counts.

It does not assume the framework can see it die. Economy guards its store with store == null on an interface-typed reference — a plain reference comparison, which Unity's destroyed-object null never reaches. A MonoBehaviour handed out as a framework interface has to refuse for itself, so these members check first.

It does not replace the inner store's failures with its own. When the bag refuses and the drop cannot be made either, the bag's result comes back untouched — a caller reading NoSpace still sees NoSpace, not "no prefab assigned". The buyer's problem is still the full bag.