Demos/TarkovInventory/Loadout.cs
using System.Collections.Generic;

namespace Sandbox.TarkovInventory;

// A typed equip slot: id, display label, and the kinds it accepts.
public sealed record EquipSlot( string Id, string Label, IReadOnlySet<ItemKind> Accepts )
{
    public bool CanHold( ItemKind kind ) => Accepts.Contains( kind );
}

// Distinguishes a rejected equip (Equipped false) from a successful one that
// displaced nothing (Equipped true, Displaced null).
public readonly record struct EquipResult( bool Equipped, string? Displaced );

// Pure equip model: a fixed set of typed slots, each holding at most one item key.
// Keys are item ids (string), matching Grid<string>; kind is supplied per call so
// the model stays decoupled from StashItem.
public sealed class Loadout
{
    readonly Dictionary<string, EquipSlot> _slots = new();
    readonly List<EquipSlot> _order = new();
    readonly Dictionary<string, string> _occ = new();   // slotId -> item key; SlotOf reverse-scans (fine for small loadouts)

    public Loadout( IEnumerable<EquipSlot> slots )
    {
        foreach ( var s in slots )
        {
            if ( !_slots.TryAdd( s.Id, s ) )
                throw new System.ArgumentException( $"duplicate slot id '{s.Id}'", nameof( slots ) );
            _order.Add( s );
        }
    }

    public IReadOnlyList<EquipSlot> Slots => _order;

    public EquipSlot? Slot( string slotId ) => _slots.TryGetValue( slotId, out var s ) ? s : null;

    public string? Occupant( string slotId ) => _occ.TryGetValue( slotId, out var id ) ? id : null;

    public string? SlotOf( string itemKey )
    {
        foreach ( var kv in _occ )
            if ( kv.Value == itemKey ) return kv.Key;
        return null;
    }

    public bool CanEquip( string slotId, ItemKind kind ) =>
        _slots.TryGetValue( slotId, out var s ) && s.CanHold( kind );

    // Swap-on-occupied. Rejected (unknown slot or type mismatch) leaves the slot unchanged.
    public EquipResult Equip( string slotId, string itemKey, ItemKind kind )
    {
        if ( !CanEquip( slotId, kind ) ) return new EquipResult( false, null );
        if ( _occ.TryGetValue( slotId, out var cur ) )
        {
            if ( cur == itemKey ) return new EquipResult( true, null );   // already there: no-op
            _occ[slotId] = itemKey;
            return new EquipResult( true, cur );                          // swap: evict cur
        }
        _occ[slotId] = itemKey;
        return new EquipResult( true, null );
    }

    public string? Unequip( string slotId )
    {
        if ( _occ.TryGetValue( slotId, out var id ) ) { _occ.Remove( slotId ); return id; }
        return null;
    }
}