Skip to content

A bag that grows with the character

Level up and the backpack gets four more slots. Put on the pack and it gets four more again. Take the pack off with the bag full, and nothing is deleted — the bag stays large and shrinks the moment there is room.

Recipe

Systems required: Attributes, Inventory, and Save for one of the reconcile points. Package: Complete. Shape: one class you drop onto a character that already exists. Public API only. It assumes: the character has an AttributeSet, and a container the inventory service knows about. It never creates one. Once you change it, it is your code. Read the shrink section before you change the one parameter this recipe is careful about.

Inventory has always been able to do this

IInventoryService.ResizeContainer is public, and exactly one component in the framework calls it: InventorySizeSync, which applies a fixed policy once in Start at execution order −350 and never looks again. Pointing it at a live attribute is three lines.

The other two hundred are about what happens when the number goes down, and about the fact that nothing tells you when it changes.

Do not leave InventorySizeSync pointed at a container this component drives

Both default to Backpack, and two components sizing one container is a wiring mistake whichever of them is right. The ordering makes it worse than a tie: −350 orders InventorySizeSync among Start calls, and every OnEnable in the scene runs before any Start. So on a scene load the fixed policy landed after this component's first reconcile and silently won — while LastAppliedCapacity and TargetCapacity went on agreeing with each other about a number the container did not have, which is the one failure this recipe's own debug advice cannot see.

This component reconciles from Start as well as OnEnable, so a load now ends on the attribute. That settles the collision; it does not make it a good idea. Remove the other component, or point it at a container this one does not touch.

There is no event for the value this reads

AttributeSet raises BaseValueChanged for base values it owns. Effective values raise nothing, and that is deliberate rather than missing: an effective value is base plus whatever providers on the chain contribute through your combiner, and the container cannot know when a provider's answer changes any more than Health knows when a damage rule would answer differently.

So a capacity that moved because a backpack was equipped, or because a buff expired, is not observable by subscription. Choosing when to look is this recipe.

The moments it looks:

Moment Why
OnEnable Whatever is worn or running was there before this woke up — a restored save, a prefab authored with a slot filled.
Start Every OnEnable runs before any Start, so anything that sizes the container from Start would otherwise land after that first reconcile and win. See the danger box above.
Any BaseValueChanged Not just this attribute's. See the warning below.
RevSaveManager.LoadCompleted Inventory restored a slot count of its own. See further below.
Your calls to Reconcile() Equip, unequip, status applied, status expired. None of these raises an attribute event, because none of them changes a base value.

Do not filter the change event on the attribute id

if (delta.id == capacityAttributeId) looks obviously right and breaks the moment capacity is derived. A combiner computing carry from might — see three stacking rules — means a change to might moves the answer while raising an event that names might.

Reconciling on every base-value change costs a comparison and removes a whole class of "it updates, except when…".

This is the one place the interface is not enough

IAttributeSource is the right thing to read a value through, and it carries no event at all. A consumer that wants to react has to hold the concrete AttributeSet — and even then only gets base-value changes. Worth knowing before designing something against the interface alone.

Shrinking is where the recipe earns its page

Growing always works. Shrinking has three outcomes.

A shrink does not compact first — it only looks past the new size

A twenty-slot bag holding one item, in slot nineteen, refuses to shrink to ten. Nineteen slots are free and the resize fails anyway, because the check walks slots newSize..old and finds one non-empty.

That is not a bug — compacting somebody's bag as a side effect of a resize would be worse — but it is nothing like what "shrink to ten" sounds like, and it is why this recipe has a Sort Before Shrinking switch that is off by default. Sorting a player's inventory because a buff expired is a decision your game should make on purpose.

allowTruncate: true succeeds by handing you the items it dropped

It returns Ok. It also fills an out List<ItemStack> with everything that no longer fits — and a caller that ignores that parameter has deleted the player's items while reading a success result.

This recipe passes false on every path. There is no correct generic answer to where truncated stacks should go; spilling them on the ground is one, and crafts that fall on the floor is the shape of it.

So the third outcome is: the bag stays as it is, Reconcile() returns false, and one line is logged per spell of not fitting. The bag being larger than the character's strength says is a cosmetic problem that fixes itself; the alternative is not cosmetic.

Zero slots is refused, so the floor is required rather than defensive

ResizeContainer fails InvalidArgs for any size at or below zero, and attribute values may legitimately be negative — the container imposes no sign convention. Minimum Slots is what stops a debuff turning into a refused resize every frame.

Two systems save two different facts

After a load, the container holds the size it had when the save was taken.

That is not a double write. Attributes persists base values only; the slot count is Inventory's state and Inventory saves it, the same way it saves what is in the slots. Two systems own two different facts and neither writes the other's.

Inventory saves one container per character, and it is the bound one

The shipped InventorySaveParticipant captures owners carrying a CharacterInventory and a StableId, and for each one it snapshots the container that component binds — its inspectorContainer, Backpack by default — plus equipment.

A second container this component drives is captured by nothing: not its capacity, and not what is in it. So "Inventory saves it" is true of the bag CharacterInventory is pointed at, and persisting any other one is yours to arrange — the same rule as a contributor that belongs to no framework system. Worth knowing before the second copy of this component goes on, because the failure is invisible until someone reloads.

What they do not have is an ordering guarantee, and neither restore can be relied on to announce itself. Attributes writes base values directly and raises nothing at all; Inventory's restore does raise container events, but they describe slots being refilled rather than a capacity decision. One is silent and the other is misleading, so nothing here can be event-driven either way. Reconciling from LoadCompleted, the point every system's presentation reconciles from, settles it, and settles it the same way whether the saved size was larger or smaller than the attribute now asks for.

Reading a capacity creates the container

IReadOnlyInventoryService.Get resolves the container lazily on the shipped service, at whatever size its own policy gives. So a read-only-looking call materialises it, and ResizeContainer would have created one too — at the target size, taking over a decision InventorySizeSync applies at −350. This recipe returns early on a null view rather than creating anything itself.

Drop it in

RoomToCarry.cs
using RevGaming.RevFramework.Attributes.Abstractions;
using RevGaming.RevFramework.Attributes.UnityIntegration;
using RevGaming.RevFramework.Core.Save;
using RevGaming.RevFramework.Inventory.Abstractions;
using RevGaming.RevFramework.Inventory.UnityIntegration;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.RoomToCarry
{
    /// <summary>
    /// Sizes a container from an attribute: a stronger character carries more, and a weaker one
    /// carries less without anything being thrown away.
    /// </summary>
    /// <remarks>
    /// <para><b>Recipe.</b> One class you drop into a project that already exists. Systems required:
    /// <b>Attributes</b>, <b>Inventory</b>, and <b>Save</b> for one of the reconcile points. Public
    /// API only. It goes on the character, with its <c>AttributeSet</c>.</para>
    ///
    /// <para><b>Inventory has always been able to do this and nothing has ever driven it.</b>
    /// <c>ResizeContainer</c> is public; the one component that calls it,
    /// <c>InventorySizeSync</c>, applies a fixed policy once in <c>Start</c> and never looks
    /// again. Pointing it at a live attribute is the whole idea, and it is three lines. The other
    /// two hundred are about what happens when the number goes down.</para>
    ///
    /// <para><b>Do not leave <c>InventorySizeSync</c> pointed at a container this drives.</b> Both
    /// default to <c>Backpack</c>, and two components sizing one container is a wiring mistake
    /// whichever of them is right — this one reconciles from <c>Start</c> as well as
    /// <c>OnEnable</c> so a scene load ends on the attribute rather than the fixed policy, but the
    /// fix is to remove the other component or point it somewhere else.</para>
    ///
    /// <para><b>There is no event for the value this recipe reads.</b> <c>AttributeSet</c> raises
    /// <c>BaseValueChanged</c> for base values it owns, and <i>effective</i> values — base plus
    /// whatever the providers on the chain contribute, through the project's combiner — deliberately
    /// have none, because the container cannot know when a provider's answer changes. So capacity
    /// that moves because a backpack was equipped, or because a buff expired, is not observable by
    /// subscription. Choosing when to look is this recipe's actual work, and
    /// <see cref="Reconcile"/> is public because half those moments belong to your game.</para>
    ///
    /// <para><b>It reads the concrete <see cref="AttributeSet"/> rather than
    /// <see cref="IAttributeSource"/>, and only for the event.</b> The interface is the right thing
    /// to read a value through and carries no event at all; a consumer that wants to *react* has to
    /// hold the component. Worth knowing before you design something on the interface alone.</para>
    ///
    /// <para><b>It never truncates.</b> <c>ResizeContainer</c> will drop items on request, handing
    /// them back in an out parameter for you to deal with — and a recipe that took that option would
    /// delete a player's inventory the first time a buff wore off. See <see cref="Reconcile"/> for
    /// the three ways a shrink can go and what this does about each.</para>
    /// </remarks>
    [DisallowMultipleComponent]
    [AddComponentMenu("RevFramework/Cookbook/Room To Carry")]
    public sealed class RoomToCarry : MonoBehaviour
    {
        [Tooltip("The attributes being read. Defaults to one on this GameObject.")]
        [SerializeField] private AttributeSet attributes;

        [Tooltip("The inventory service. Leave empty to find one in the scene on first use.")]
        [SerializeField] private SceneInventoryService inventory;

        [Tooltip("Whose container is resized. Defaults to this GameObject.")]
        [SerializeField] private GameObject owner;

        [Tooltip("Container to resize. Matches Inventory's own naming; ids are normalised to lowercase.")]
        [SerializeField] private string containerName = "Backpack";

        [Tooltip("Attribute read as the slot count. Its EFFECTIVE value, so gear and buffs count.")]
        [SerializeField] private string capacityAttributeId = "carry";

        [Tooltip("The container never shrinks below this, whatever the attribute says. A resize to " +
                 "zero or fewer slots is refused by Inventory outright, so this floor is required " +
                 "rather than defensive: attribute values may legitimately be negative.")]
        [SerializeField, Min(1)] private int minimumSlots = 1;

        [Tooltip("When a shrink is refused because the tail slots are occupied, tidy the container " +
                 "and try once more. Off by default: sorting a player's bag as a side effect of a " +
                 "stat change is a decision your game should make on purpose.")]
        [SerializeField] private bool sortBeforeShrinking;

        [Tooltip("Optional. When assigned, a completed load triggers a reconcile — Inventory restores " +
                 "its own saved slot count, which is not this component's number.")]
        [SerializeField] private RevSaveManager saveManager;

        /// <summary>The capacity this component last successfully applied, or −1 if it never has.</summary>
        /// <remarks>Side-effect free, so a debug overlay can read it every frame.</remarks>
        public int LastAppliedCapacity { get; private set; } = -1;

        /// <summary>
        /// The slot count the attribute currently asks for, before any of it is applied.
        /// </summary>
        /// <remarks>
        /// <para>Side-effect free, and worth showing in a UI: the number a shrink is <i>trying</i> to
        /// reach explains a bag that has stayed large, where <see cref="LastAppliedCapacity"/> alone
        /// looks like the component is doing nothing.</para>
        ///
        /// <para><b>Floored, matching <c>AttributeLevelSource</c>'s conversion for the one seam the
        /// framework ships an attribute adapter for.</b> A carry of 9.9 is nine slots — partial
        /// progress toward a slot is not a slot — and flooring rounds a negative value toward less
        /// entitlement rather than more, before the floor below catches it anyway.</para>
        /// </remarks>
        public int TargetCapacity
        {
            get
            {
                if (!attributes || string.IsNullOrWhiteSpace(capacityAttributeId))
                    return -1;

                return attributes.TryGetValue(capacityAttributeId, out float value)
                    ? Mathf.Max(Mathf.Max(1, minimumSlots), Mathf.FloorToInt(value))
                    : -1;
            }
        }

        private bool _warnedNoRoom;

        private void Reset()
        {
            attributes = GetComponent<AttributeSet>();
            owner = gameObject;
        }

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

            if (attributes)
                attributes.BaseValueChanged += OnBaseValueChanged;

            if (saveManager)
                saveManager.LoadCompleted += OnLoadCompleted;

            // Whatever is worn or running was there before this component woke up — on a restored
            // save, or on a prefab authored with a slot filled. Reconciling here is what makes those
            // cases behave like a change that happened while watching.
            Reconcile();
        }

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

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

        /// <summary>
        /// Reconciles again once every <c>Start</c> has run, because <c>OnEnable</c> is too early to
        /// be the last word on a container's size.
        /// </summary>
        /// <remarks>
        /// <para><b>Unity runs every <c>OnEnable</c> before any <c>Start</c></b>, so anything that
        /// sizes a container from <c>Start</c> lands <i>after</i> this component's first reconcile
        /// and silently wins — including the shipped <c>InventorySizeSync</c>, whose
        /// <c>-350</c> execution order sequences it among <c>Start</c> calls and not against
        /// <c>OnEnable</c> at all. Without this second pass a scene load left the bag at the fixed
        /// policy size while <see cref="LastAppliedCapacity"/> and <see cref="TargetCapacity"/>
        /// agreed with each other about a number the container did not have.</para>
        ///
        /// <para>It is the same idempotent call, so when nothing else wrote the size it costs one
        /// comparison. Two components driving one container is still a wiring mistake — see the
        /// page — and this only decides which of them a scene load ends on.</para>
        /// </remarks>
        private void Start() => Reconcile();

        /// <summary>
        /// Reconciles on any base-value change, not only this attribute's.
        /// </summary>
        /// <remarks>
        /// Filtering on <c>delta.id</c> looks obviously right and is wrong as soon as the capacity
        /// attribute is <i>derived</i> — a combiner computing <c>carry</c> from <c>might</c> means a
        /// change to <c>might</c> moves the answer while raising an event that names <c>might</c>.
        /// The reconcile is cheap and idempotent, so answering every change costs a comparison and
        /// removes a whole class of "it updates except when…".
        /// </remarks>
        private void OnBaseValueChanged(AttributeDelta delta) => Reconcile();

        /// <summary>
        /// Reconciles after a load, because Inventory restored a slot count of its own.
        /// </summary>
        /// <remarks>
        /// <para><b>This is not a double-write, and it is worth being precise about why.</b>
        /// Attributes persists base values only; the slot count is Inventory's state and Inventory
        /// saves it, exactly as it saves what is in the slots. Two systems own two different facts.
        /// What they do not have is an ordering guarantee, and neither restore can be relied on to
        /// announce itself: Attributes writes base values directly and raises nothing at all, while
        /// Inventory's restore raises container events that describe slots being refilled rather than
        /// a capacity decision. One is silent and the other is misleading, so nothing here can be
        /// event-driven either way — which is why this reconciles from <c>LoadCompleted</c>.</para>
        ///
        /// <para><b>"Inventory saves it" is scoped to one container per character.</b> The shipped
        /// participant captures owners carrying a <c>CharacterInventory</c> and a <c>StableId</c>,
        /// and snapshots the container that component binds — its <c>inspectorContainer</c>,
        /// <c>Backpack</c> by default — together with equipment. A <i>second</i> container this
        /// component drives is captured by nothing: not its capacity, and not what is in it.
        /// Persisting that one is yours to arrange, the same way a contributor belonging to no
        /// framework system is yours to persist.</para>
        ///
        /// <para>So after a load the container holds the size it had when the save was taken, which
        /// is right if nothing has changed and stale if the attribute has. Reconciling from
        /// <c>LoadCompleted</c> — the point every system's presentation reconciles from — settles
        /// it, and settles it the same way whether the save was newer or older.</para>
        /// </remarks>
        private void OnLoadCompleted(string slot, RevSaveReport report) => Reconcile();

        /// <summary>
        /// Brings the container's capacity in line with the attribute, and reports whether it got there.
        /// </summary>
        /// <returns>
        /// True when the container ends this call at the size the attribute asks for — including
        /// when it was already there. False when the target could not be reached, which is a normal
        /// state rather than an error: the bag stays as it is and the next reconcile tries again.
        /// </returns>
        /// <remarks>
        /// <para><b>Call this yourself at the moments the framework cannot tell you about</b> — after
        /// an equip or unequip, after a status is applied or expires, after your own code grants a
        /// contribution. None of those raises an attribute event, because none of them changes a base
        /// value. It is idempotent and cheap when nothing has moved.</para>
        ///
        /// <para><b>Growing always works. Shrinking has three outcomes and only one of them is
        /// simple.</b></para>
        /// <list type="number">
        /// <item><description><b>It fits.</b> The tail slots are empty and the resize succeeds.</description></item>
        /// <item><description><b>The tail is occupied.</b> The resize inspects only the slots
        /// <i>beyond</i> the new size and refuses if any is non-empty — it does not compact first. So
        /// a twenty-slot bag holding one item in slot nineteen refuses to shrink to ten even though
        /// nineteen slots are free. With <c>sortBeforeShrinking</c> the container is tidied
        /// (<c>emptySlotsLast</c> is the compaction) and tried once more; without it the bag stays
        /// large.</description></item>
        /// <item><description><b>It genuinely will not fit.</b> The bag stays at its current size and
        /// says so once. <b>The alternative is <c>allowTruncate: true</c>, which succeeds by handing
        /// you the dropped stacks in an out parameter — and a caller that ignores that parameter has
        /// deleted them.</b> There is no correct generic answer to where they should go; spilling
        /// them on the floor is one, and
        /// <see href="../SpilledCraft/README.md">crafts that fall on the floor</see> shows the
        /// shape.</description></item>
        /// </list>
        ///
        /// <para><b>An authority can refuse the resize, and that is not a failure to report.</b>
        /// <c>ResizeContainer</c> goes through the same <c>IInventoryAuthority</c> check as every
        /// other mutation, so <see href="../Lockdown/README.md">a cutscene lock</see> makes this
        /// return false for as long as it is engaged. Logging that would log it every reconcile for
        /// the length of the cutscene; the next reconcile after the lock lifts fixes the size.</para>
        /// </remarks>
        public bool Reconcile()
        {
            if (!attributes || !owner || string.IsNullOrWhiteSpace(capacityAttributeId))
                return false;

            // Not cached in Awake: this component is usable from a prefab that was never in a scene
            // when the service appeared. Inactive ones count — a service on a bootstrap object that
            // happens to be switched off at this instant is still the service.
            if (!inventory)
                inventory = FindAnyObjectByType<SceneInventoryService>(FindObjectsInactive.Include);

            if (!inventory)
                return false;

            int target = TargetCapacity;
            if (target < 0)
                return false;   // The owner has no such attribute. Leave the bag alone entirely.

            var containerId = new ContainerId(containerName);

            IReadOnlyInventoryContainer container = inventory.Get(owner, containerId);

            // A null view means the owner has gone, or the service is one of your own that does not
            // create containers on demand. The shipped SceneInventoryService always answers:
            // Get() resolves the container lazily at whatever size its own policy gives, so reading
            // a capacity through the read-only surface is what MATERIALISES the container. Worth
            // knowing before calling TargetCapacity from a HUD on a character who has never opened
            // their bag — and worth knowing that ResizeContainer would have created one too, at the
            // target size, taking over a decision InventorySizeSync applies from Start. Its -350
            // execution order orders it among Starts, which is all of them after every OnEnable:
            // see Start above for why that costs this component a second reconcile.
            if (container == null)
                return false;

            int current = container.Capacity;

            if (current == target)
            {
                LastAppliedCapacity = target;
                _warnedNoRoom = false;
                return true;
            }

            // allowTruncate is false on every path here. It is the parameter that turns "the bag
            // could not shrink" into "the bag shrank and something is gone".
            InvOpResult result = inventory.ResizeContainer(owner, containerId, target,
                                                           allowTruncate: false, out _);

            if (!result.Success && result.Code == InvOpCode.NoSpace && sortBeforeShrinking)
            {
                // Only NoSpace is worth tidying for. Retrying a NoAuthority refusal after sorting
                // would sort the bag during a cutscene and still fail.
                inventory.Sort(owner, containerId, InventorySortSpec.ByNameAsc());

                result = inventory.ResizeContainer(owner, containerId, target,
                                                   allowTruncate: false, out _);
            }

            if (result.Success)
            {
                LastAppliedCapacity = target;
                _warnedNoRoom = false;
                return true;
            }

            if (result.Code == InvOpCode.NoSpace && !_warnedNoRoom)
            {
                // Once per spell of not fitting, and reset by the next success. A stat that keeps
                // flickering across the boundary would otherwise fill the console with a message
                // whose news value ran out the first time.
                _warnedNoRoom = true;

                Debug.Log($"[{nameof(RoomToCarry)}] '{owner.name}' should carry {target} slots but is " +
                          $"holding items past that point, so the container stays at {current}. " +
                          "Nothing was dropped. Make room, or turn on Sort Before Shrinking.", this);
            }

            return false;
        }
    }
}

Wiring it up

  1. Put an AttributeSet on the character with a carry attribute — or whatever you call it.
  2. Wire a combiner, or contributions will not count. Nothing happens without one, and the console says so once: see three stacking rules.
  3. Add this component. Point it at the container by name; the default is Backpack, matching Inventory's own naming. If an InventorySizeSync in the scene names the same container, remove it or repoint it — see the danger box above.
  4. Set Minimum Slots to the smallest bag your game should ever show. It is a floor on the result, not on the attribute.
  5. Call Reconcile() after equipping, unequipping, and after statuses are applied or expire. Those are the moments nothing else will tell you about. It is idempotent and cheap when nothing moved.
  6. Assign the RevSaveManager if you use one, so a load reconciles.
  7. Show TargetCapacity next to LastAppliedCapacity in a debug overlay. A bag that has stayed large because it could not shrink looks identical to a component that is not running, and those two numbers are the difference.

Where the extra slots come from is not this component's business

A carry attribute raised by SetBaseValue on level-up, a backpack contributing through an equipment provider, a Derived route computing carry from might — all of them arrive as the same effective value. That is the point of routing capacity through an attribute rather than counting backpacks here.

What it deliberately does not do

It does not create containers. A null view means the owner is gone, or you are using a service of your own; the shipped one always answers. Creating one at the target size would quietly override the service's size policy.

It does not report an authority refusal. ResizeContainer goes through the same IInventoryAuthority check as every other mutation, so a cutscene lock makes this return false for as long as it is engaged. Logging that would log it every reconcile for the length of the cutscene; the next reconcile after the lock lifts fixes the size.

It does not poll. There is no Update. If your game changes contributions in ways it cannot name a moment for, a coroutine calling Reconcile() a few times a second is a legitimate answer and a deliberate one — but name the moments first, because there are usually fewer than you expect.

It does not resize more than one container. Two containers driven by two attributes are two copies of this component, which is cheaper than a list of pairs and easier to read in the inspector. Two things to know before you do it.

[DisallowMultipleComponent] means the second copy cannot sit beside the first, so it goes on another GameObject — and there both defaults misfire. Attributes looks only at its own object and finds nothing, leaving that copy inert with no log; and if you fix only that, Owner still defaults to the object the copy is on, keying a container the character's CharacterInventory, its UI and the save participant never see. Assign Attributes and Owner by hand on any copy that is not on the character itself.

And only one container is covered by Inventory's save participant — see the warning above before you rely on the second surviving a reload.