Skip to content

A tidy that leaves your favourites alone

The player has arranged their hotbar the way they like it. Then they hit Sort, and the healing potion they always keep in slot two is now somewhere in the middle of forty items, sorted alphabetically between a Hunting Knife and an Iron Ingot. What they wanted was the mess tidied, not the parts they had already decided about.

Recipe

Systems required: Inventory. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: you have a SceneInventoryService and a Sort button wired to it. It installs itself into the service on enable. 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

The sorter is not handed a list of items. It is handed a list of items and the slot each one came from:

void Sort(List<(ItemStack stack, int originalIndex)> items, InventorySortSpec spec);

That second element is easy to read as a convenience. It is not. It is how the service keeps a promise it makes in its own documentation.

SceneInventoryService.Sort promises a stable sort. List.Sort is not one.

List<T>.Sort is introsort. It is not stable — equal elements can come out in any order — except that below seventeen elements it falls back to insertion sort, which is.

The shipped DefaultInventorySorter does not depend on the sort being stable. It makes stability unnecessary, by ending every comparison with the one key that can never tie:

return x.originalIndex.CompareTo(y.originalIndex);

That turns the comparison into a total order. No ties survive, so there is nothing for an unstable sort to reorder.

Leave that line out of your own sorter and you break the service's contract invisibly. Sixteen items or fewer and everything looks perfect, because insertion sort is stable anyway. It starts misbehaving on the seventeenth — which is to say, in the real game and not in your test. This class ends its comparison the same way, for the same reason.

The index is an input, never an output

After your sorter returns, the service writes the list back by position:

for (int i = 0; i < items.Count; i++)
    if (!StacksEqual(slots[i].stack, items[i].stack))
        inv.SetStackAtResult(i, items[i].stack, out _);

It never reads originalIndex again.

So rewriting the index to mean 'put this back where it was' does nothing

The index tells you where an item came from. It has no power to say where it should go. To keep an item in slot five, you must leave it at list position five — which is exactly what this recipe does with a pinned item, and the only mechanism available.

The list length is an invariant nobody checks

Look at that loop again. It runs items.Count times — a count the sorter controls — against a container whose size it does not.

One direction throws. The other is the quiet one.

  • Return more entries than you were given and the service throws. Look at the order: it reads slots[i] before it writes, so the read runs out of range on the first surplus entry and raises ArgumentOutOfRangeException. It never reaches SetStackAtResult's bounds check at all. Loud, immediate, and easy to find.
  • Return fewer and nothing is reported at all. The loop simply stops early. The tail slots are never written, so they keep their old contents — and anything you did place into the head is now in the container twice.

Sort in place. Reorder only. Never add, never remove. This class fills every position exactly once, from either the pinned set or the sorted remainder, so its count is unchanged by construction rather than by luck.

SetSorter installs a strategy, there is no getter to read the one already there, and DefaultInventorySorter is internal. So this class has to re-answer every case the default answered, exactly as the craftable search box did with SetSearch.

Though the burden turns out to be small, and that is worth knowing too

The framework's own InventorySortHelpers is internal as well — but everything it reads is public. ItemDefinition.rarity is a public field, NormalizedCategory is a public property, so the helper is one line to restate rather than a wall:

private static int Rarity(in ItemStack s)
    => (s.IsEmpty || s.def == null) ? 0 : s.def.rarity;

Turning the fireworks off had it easier still — its default is public, so it wraps rather than restates. Three seams, three different amounts of work, decided entirely by which things happen to be internal.

Two limits, stated rather than hidden

Pinning is by item, not by stack

The seam sees ItemStacks, and there is nothing in a stack to tell one from another of the same item. So pinning a Health Potion pins every stack of Health Potions. If you need one specific stack pinned, that identity has to come from ItemStack.meta, which is yours to define.

This preserves a position, it does not assign one

A pinned item keeps whatever slot it was in when the sort ran. It is not moved to a favourite slot. If you want "always slot two", that is a different feature and it belongs on the way in, not in the sorter.

Drop it in

PinnedSort.cs
using System.Collections.Generic;

using RevGaming.RevFramework.Inventory.Abstractions;
using RevGaming.RevFramework.Inventory.Data;
using RevGaming.RevFramework.Inventory.UnityIntegration;

using UnityEngine;

namespace RevGaming.RevFramework.Cookbook.PinnedSort
{
    /// <summary>
    /// Tidies the bag without moving the items the player pinned — they keep the exact slot they
    /// were in, and everything else sorts around them.
    /// </summary>
    /// <remarks>
    /// <para><b>The seam is handed the slot each item came from, and that is not decoration.</b>
    /// <c>Sort</c> receives <c>List&lt;(ItemStack stack, int originalIndex)&gt;</c>.
    /// <c>SceneInventoryService.Sort</c> documents itself as a <b>stable</b> sort, and
    /// <c>List&lt;T&gt;.Sort</c> is introsort — <b>not</b> stable above sixteen elements. The shipped
    /// sorter does not rely on the sort being stable; it makes stability unnecessary by ending every
    /// comparison with <c>originalIndex.CompareTo</c>, so no ties survive for an unstable sort to
    /// reorder.</para>
    ///
    /// <para><b>Omit that last line and you break the service's documented contract invisibly.</b>
    /// Below seventeen items <c>List.Sort</c> falls back to insertion sort and is stable anyway, so a
    /// bag that misbehaves in play will look perfect in every small test. This class ends its
    /// comparison the same way, for the same reason.</para>
    ///
    /// <para><b>The index is an input, never an output.</b> After the call the service writes
    /// <c>items[i].stack</c> into slot <c>i</c> — by list position, not by the index it handed you.
    /// Rewriting <c>originalIndex</c> to say "put this back where it was" does nothing at all. To keep
    /// an item in slot five you must leave it at <i>list position</i> five, which is what this does.
    /// </para>
    ///
    /// <para><b>The list length is an invariant, and the two ways to break it fail differently.</b>
    /// The write loop reads <c>slots[i]</c> before it writes, so returning <i>more</i> entries than
    /// you were given throws <c>ArgumentOutOfRangeException</c> on that read — it never reaches the
    /// bounds check on the write, and it is loud. Returning <i>fewer</i> is the quiet one: the loop
    /// stops early, the tail slots keep their old contents, and anything also placed into the head is
    /// now in the container twice. Sort in place, reorder only, and never add or remove.</para>
    ///
    /// <para>Two limits, stated rather than hidden. Pinning is by <see cref="ItemDefinition"/>, so
    /// every stack of a pinned item is pinned — the seam sees stacks, not individual objects, and
    /// there is nothing in a stack to tell two of the same item apart. And a pinned item holds the
    /// slot it happened to be in when the sort ran; this preserves position, it does not assign one.
    /// </para>
    /// </remarks>
    [AddComponentMenu("RevFramework/Cookbook/Pinned Sort")]
    public sealed class PinnedSort : MonoBehaviour, IInventorySorter
    {
        [Tooltip("The inventory service to install into. Left empty, this component sorts nothing and " +
                 "says so once on enable.")]
        [SerializeField] private SceneInventoryService inventory;

        [Tooltip("Items that keep their slot when the bag is sorted.")]
        [SerializeField] private ItemDefinition[] pinnedItems;

        private readonly HashSet<ItemDefinition> _pinned = new();
        private readonly List<(ItemStack stack, int originalIndex)> _loose = new();
        private bool _warnedNoService;

        private void OnEnable()
        {
            if (inventory)
            {
                inventory.SetSorter(this);
                return;
            }

            // Said out loud, because this failure has no other symptom. An unassigned field means
            // SetSorter is never called, the shipped sorter keeps answering, and Sort keeps working --
            // it just ignores the pins. Nothing looks broken; the feature is simply absent. Latched,
            // because a scene left half-wired would otherwise warn on every enable.
            if (_warnedNoService) return;

            _warnedNoService = true;
            Debug.LogWarning(
                $"[Cookbook] '{name}' has no {nameof(SceneInventoryService)} assigned, so it never " +
                "installs itself and pinned items will be sorted like everything else. Assign one.",
                this);
        }

        /// <summary>Replaces the pinned set. A null array pins nothing.</summary>
        public void SetPinned(ItemDefinition[] items) => pinnedItems = items;

        /// <summary>
        /// Sorts every unpinned stack by <paramref name="spec"/>, leaving pinned stacks in place.
        /// </summary>
        public void Sort(List<(ItemStack stack, int originalIndex)> items, InventorySortSpec spec)
        {
            if (items == null || items.Count <= 1) return;

            _pinned.Clear();
            if (pinnedItems != null)
                for (int i = 0; i < pinnedItems.Length; i++)
                    if (pinnedItems[i]) _pinned.Add(pinnedItems[i]);

            if (_pinned.Count == 0)
            {
                // Nothing pinned is not a special case worth branching for correctness -- the general
                // path below would produce the same answer -- but it is the common one, and skipping
                // the partition keeps it a plain sort.
                items.Sort(Compare(spec));
                return;
            }

            // Which list positions are spoken for. A pinned stack keeps the position it arrived at,
            // which is the only way to hold a slot: the service writes by position, not by index.
            var held = new bool[items.Count];
            for (int i = 0; i < items.Count; i++)
                if (IsPinned(items[i].stack))
                    held[i] = true;

            _loose.Clear();
            for (int i = 0; i < items.Count; i++)
                if (!held[i])
                    _loose.Add(items[i]);

            _loose.Sort(Compare(spec));

            // Write back in place. Count is unchanged by construction: every position is filled
            // exactly once, from either the held set or the sorted remainder.
            int next = 0;
            for (int i = 0; i < items.Count; i++)
                if (!held[i])
                    items[i] = _loose[next++];
        }

        private bool IsPinned(in ItemStack stack)
            => !stack.IsEmpty && stack.def != null && _pinned.Contains(stack.def);

        /// <summary>
        /// The shipped ordering, restated.
        /// </summary>
        /// <remarks>
        /// <c>DefaultInventorySorter</c> is <c>internal</c>, so there is nothing to delegate to and no
        /// getter to read the strategy this displaced — the same wall
        /// <c>CraftableSearch</c> hits with <c>SetSearch</c>. Everything needed is public, though:
        /// <c>ItemDefinition.rarity</c> is a public field, so the framework's own internal
        /// <c>RarityOrZero</c> helper is one line to restate rather than a blocker.
        ///
        /// The final <c>originalIndex</c> comparison is the stability contract and is not optional.
        /// </remarks>
        private static System.Comparison<(ItemStack stack, int originalIndex)> Compare(InventorySortSpec spec)
        {
            return (x, y) =>
            {
                bool xe = x.stack.IsEmpty || x.stack.def == null;
                bool ye = y.stack.IsEmpty || y.stack.def == null;

                if (spec.emptySlotsLast && xe != ye)
                    return xe ? 1 : -1;

                int cmp = Key(spec.primary, x.stack, y.stack);
                if (!spec.ascending) cmp = -cmp;
                if (cmp != 0) return cmp;

                if (spec.then.HasValue)
                {
                    cmp = Key(spec.then.Value, x.stack, y.stack);
                    if (!spec.thenAscending) cmp = -cmp;
                    if (cmp != 0) return cmp;
                }

                // Total order, so no tie can reach List.Sort's unstable path. See the remarks.
                return x.originalIndex.CompareTo(y.originalIndex);
            };
        }

        private static int Key(InventorySortKey key, in ItemStack a, in ItemStack b)
        {
            switch (key)
            {
                case InventorySortKey.Name:
                    return string.Compare(Name(a), Name(b), System.StringComparison.OrdinalIgnoreCase);

                case InventorySortKey.Category:
                    {
                        int c = string.Compare(Category(a), Category(b), System.StringComparison.Ordinal);
                        return c != 0 ? c : string.Compare(Name(a), Name(b), System.StringComparison.OrdinalIgnoreCase);
                    }

                case InventorySortKey.Quantity:
                    return a.quantity.CompareTo(b.quantity);

                case InventorySortKey.Rarity:
                    {
                        int c = Rarity(a).CompareTo(Rarity(b));
                        return c != 0 ? c : string.Compare(Name(a), Name(b), System.StringComparison.OrdinalIgnoreCase);
                    }

                default:
                    return 0;
            }
        }

        private static string Name(in ItemStack s)
        {
            if (s.IsEmpty || s.def == null) return string.Empty;

            string dn = s.def.displayName;
            return string.IsNullOrEmpty(dn) ? (s.def.name ?? string.Empty) : dn;
        }

        private static string Category(in ItemStack s)
            => (s.IsEmpty || s.def == null) ? string.Empty : (s.def.NormalizedCategory ?? string.Empty);

        private static int Rarity(in ItemStack s)
            => (s.IsEmpty || s.def == null) ? 0 : s.def.rarity;
    }
}

Wiring it up

  1. Put the component anywhere in the scene and assign your SceneInventoryService to inventory. It installs itself in OnEnable, and warns once if that field is empty rather than silently leaving the shipped sorter in place.
  2. List the items that should keep their slot in pinnedItems. Pinning is by ItemDefinition, so every stack of a pinned item is pinned.
  3. Sort as you already do — SceneInventoryService.Sort(owner, container, spec). Nothing about the call changes; the strategy behind it does.
  4. Drive the pinned set from your UI with SetPinned if players pick their own favourites. It replaces the array wholesale, and the set is rebuilt from it on the next sort.
  5. If you already installed a sorter of your own, decide which one wins before both call SetSorter. There is no getter, so neither can detect the other, and the last install silently takes the slot.