Corecom.zoa.inventory · v0.4.0

ZOA Inventory

Engine-free item and container models with spatial grid placement, stacking, reservations, and rule-based acceptance.

Inventory is the domain model for everything a character can carry. Its defining decision is that the interesting logic has no Unity in it. `ZOA.Inventory.Core` holds the item instances, the grid occupancy map, the reservation ledger, and the operations that move quantities between containers, and it compiles without a `UnityEngine` reference. `ZOA.Inventory.Unity` adds the authoring assets, the MonoBehaviour that owns a runtime instance, the constraint assets, and the definition lookup that bridges to the GameDatabase. That split is why a stack-merge or a rotation-fit bug can be reproduced in a plain NUnit test in under a second.

The second decision is to describe a container's shape with data. An inventory is described by an `InventoryShapeDescriptor`: a kind, a grid size, whether rotation is allowed, whether footprints collapse to 1x1, and a bag stack cap. A Tarkov-style grid, an RPG slot grid, and a weightless bag are three configurations of one runtime type rather than three implementations, so `TryAdd` behaves consistently across all of them and the UI can render any of them from the same descriptor.

Placement and permission are separate gates. `InventoryRuntime` answers "does it physically fit": free cells, stack capacity, rotation, reservations. `InventoryComponent.CanAccept` answers "is it allowed in": tag filters, type filters, weight ceilings, explicit allow-lists, evaluated by ScriptableObject constraints against an `InventoryContext`. Keeping the two apart means a full backpack and a backpack that refuses grenades produce different, accurate messages, and it lets the pure runtime stay free of rule evaluation entirely.

How it works

Concepts

Cells are addressed by coordinate, so fit is one test

`GridInventoryState` keeps a flat cell array holding the `InstanceId` occupying each cell, plus a dictionary of `PlacedItem` records carrying origin, base footprint, and orientation. Occupancy and placement are stored separately because they answer different questions: the cell array answers "is this square free" in constant time, while the record answers "where does this item start and how big is it" without a scan.

Because a placement is an origin plus an oriented size, testing whether a 2x4 rifle fits is a single rectangle sweep through `IsAreaFree`, with an optional `ignoreInstance` so an item can be tested against a position it currently partially occupies. Rotation never mutates the authored footprint: `PlacedItem` stores `BaseFootprint` and `Orientation`, and `EffectiveFootprint` composes them through `GridSize.Oriented` at read time. `TryMoveAndRotate` is therefore atomic: there is no rotate-then-move pair that can strand an item halfway through.

`TryFindFirstFit` scans in row-major order and returns the first origin that accepts the size, which makes automatic placement deterministic. Two clients adding the same item to the same state land on the same cell, which matters for a networked inventory where server and client must agree without replicating every coordinate.

Item instances are immutable records with an identity

`ItemInstance` has no setters. Every mutation goes through a `With*` helper that returns a new instance carrying the same `InstanceId`: `WithQuantity`, `WithCondition`, `WithCustomName`, `WithPinned`, `WithTag`, `WithStatePayload`. Equality is identity only, so an instance compares equal to its own updated copy, and a UI can hold a stale reference without corrupting the container's copy.

The constructor is where the invariants live rather than the caller. A non-stackable item must have quantity exactly 1, a stackable one must be within `[1..MaxStackSize]`, and condition is clamped to 0..1. Violations throw at construction, which puts the failure at the line that got it wrong instead of three systems downstream.

The strongest invariant is that an item carrying an `IItemStatePayload` must be non-stackable. A per-instance payload on a stack has no meaning, because there is no answer to whose magazine contents a stack of five rifles holds. Catching that in the constructor stops it from becoming a save-layer bug where a stack split silently duplicates or discards state.

Item state travels on the instance

A weapon's magazine contents, its installed attachments, an armour piece's durability model: these belong to one instance and must survive being stowed in a backpack, dropped in a crate, and picked up again. `ItemInstance.StatePayload` holds a package-defined POCO implementing `IItemStatePayload`, so the state travels with the item through every container operation without inventory knowing anything about ballistics.

Serialisation stays outside. `ItemStatePayloadRegistry` maps a `PayloadTypeId` string, conventionally `zoa.<domain>.<name>.v<n>`, to a factory that produces a default-valued payload, and packages self-register at boot. The save layer looks up the factory, hydrates the fields with whatever mechanism it prefers, and skips the payload entirely when the id was never registered. Skipping is the backward-compatibility policy: a save containing a payload from a package the player has since removed still loads, minus that state.

Reservations are soft locks with an explicit commit

A shop sale, a crafting recipe, and a drag in flight all have the same shape: quantity that is still in the container but must not be consumed by anything else. `InventoryReservationLedger` tracks reserved quantity per instance and every removal path consults it, so a reserved stack cannot be spent out from under the pending operation.

The lifecycle has three legs. `TryReserve` takes the soft lock, `ReleaseReservation` gives it back when the operation is abandoned, and `TryCommitReservedRemoval` consumes it as part of finalising the transaction. Committing is not the same as removing, because the commit path knows the quantity was already reserved and does not have to re-check availability against a value another system may have changed in between.

Rules are assets, evaluated against a context

An `InventoryRuleSetDefinition` holds a list of `InventoryConstraintBase` assets, and acceptance is the conjunction of all of them. Constraints are ScriptableObjects so a designer authors and shares them, but the interface they implement, `IInventoryConstraint`, is engine-free and takes an `InventoryContext` struct: the inventory's definition id, its effective capabilities, its shape descriptor, its current weight excluding the item under test, and an `IItemDefinitionLookup`.

The context carries current weight, so `MaxWeightConstraint` never queries the container. It is a pure function of its inputs, and the same rule set evaluates identically in a headless test and in a live scene. The shipped constraints cover the common filters: `AllowByTagConstraint`, `AllowByItemTypeConstraint`, `AllowByExplicitDefinitionConstraint`, `AllowByEquipmentSlotConstraint`, and `MaxWeightConstraint`. Every one returns a reason string on refusal, which a UI shows the player instead of a generic beep.

Definition data reaches the rules through `IItemDefinitionLookup`, whose Unity implementation projects an `ItemDefinition` into the engine-free `ItemDefinitionInfo` snapshot. The rules therefore see stack size, footprint, weight, category, tags, and rarity, and never a `ScriptableObject` they could accidentally mutate.

Capabilities are negotiated between definition and shape

`InventoryCapabilities` is a flags enum declared in Foundation, and an `InventoryDefinition` authors a base set. `GetEffectiveCapabilities` intersects that base set with what the shape can support, so a definition that requests Rotation on a bag shape, or on a grid shape with rotation disabled, has the flag stripped.

`InventoryRuntime` applies the same reduction in its constructor, which means the invariant holds even for a runtime constructed directly in a test with no definition asset present. No consumer has to check both the definition and the shape before offering a rotate affordance: reading `Capabilities` off the runtime is sufficient.

Containers inside containers, and containers that must be searched

`ContainerItemDefinition` is an `ItemDefinition` that also references an `InventoryDefinition` for its contents, which is how a backpack is an item you can carry and a container you can open. `ContainerInventoryRegistry` maps a container's `InstanceId` to the `InventoryRuntime` holding its contents, and `ContainerItemInventoryService` is the Unity-side owner that creates those runtimes on demand from the container definition. The registry is engine-free, so nesting can be reasoned about and tested without a scene.

Separately, an inventory can be gated behind a search. `InventoryRuntime.IsSearchRequired` plus `SearchProgress` model the survival-game loot container whose contents are hidden until the player has spent time on it, with `IsItemRevealed` and `RevealItem` controlling per-item visibility. `InventorySearchManager` drives that progressively over time with a coroutine per inventory, revealing items one at a time rather than flipping the whole container open at once.

In the editor

Screens

Screenshot pending

/screenshots/inventory-item-definition-wizard.png

The wizard shell with the four-step rail on the left and Attributes selected, showing max stack size, the width and height footprint fields, and base weight, with a populated Identity step already marked complete.

The Item Definition Wizard on the Attributes step.

Screenshot pending

/screenshots/inventory-definition-wizard-shape.png

The shape step with Grid selected, rows and columns set to something like 10 by 8, the allow-rotation toggle on, and the capability checkboxes for stacking, rotation, weight, and reservations visible below.

Choosing a shape in the Inventory Definition Wizard.

Screenshot pending

/screenshots/inventory-workbench-module.png

The Workbench window with Inventory selected, its capability strip showing Items, Inventories, Rule Sets, and Inventory Browser, and the Inventory Browser panel listing several definitions with their shape and capability summaries.

The Inventory module inside the ZOA Workbench.

Screenshot pending

/screenshots/inventory-component-inspector.png

A GameObject with an InventoryComponent, its Inventory Definition section expanded showing an assigned definition and the GameDatabase override left empty, and the Search Gate section collapsed below it.

InventoryComponent in the Inspector.

Setup

Workflow

  1. 01

    Author the shape

    Create a Grid, RPG, or Bag shape asset under Tools > ZOA > Inventory > Shapes. The shape is where dimensions and rotation live, and it is shared: one 10x8 grid shape can back every large backpack in the project, so tuning capacity is a single-asset edit.

  2. 02

    Author the rule set

    Create constraint assets for whatever the container should refuse, then reference them from an InventoryRuleSetDefinition. Prefer tag constraints over explicit definition lists where you can, since a tag survives new content being added and an allow-list does not.

  3. 03

    Assemble the InventoryDefinition

    Point an InventoryDefinition at the shape and rule set, then set base capabilities. Remember the shape has the final say: requesting Rotation on a bag or on a rotation-disabled grid is stripped by GetEffectiveCapabilities rather than honoured. The Inventory Definition Wizard walks the same choices and emits the shape, rule set, and definition together.

  4. 04

    Rebuild the GameDatabase

    New definitions must be indexed before the runtime can resolve them. Run the Foundation rebuild tool, or let the wizard trigger it, so InventoryComponent's definition lookup resolves item ids to footprints, weights, and tags at runtime.

  5. 05

    Add an InventoryComponent and install the scene service

    Drop an InventoryComponent on the carrier and assign the definition, or call Configure from a generator. Add a ZOAInventorySceneInstaller to the scene root so an IInventoryService is registered before consumers Awake, and add a loot scene installer alongside it if the scene contains ItemPickups, since grant routing moved to the loot package.

  6. 06

    Drive it from gameplay

    Build instances with ItemInstanceFactory, ask CanAccept before offering an affordance, and use TryAdd for automatic placement or TryAddAt when the player chose a cell. Subscribe to Changed rather than polling; both the component and the runtime raise it on every mutation.

Surface

Key types

InventoryRuntime

class

The engine-free type the rest of the package wraps. Owns a grid or bag backing store plus a reservation ledger, and implements every add, remove, move, rotate, split, and transfer operation. Constraint evaluation happens in InventoryComponent, above this layer.

  • InventoryRuntime(DefinitionId inventoryDefinitionId, InventoryShapeDescriptor shape, InventoryCapabilities capabilities, IItemDefinitionLookup itemDefinitions = null)
  • bool TryAdd(ItemInstance item, out string reason)
  • bool TryAddAt(ItemInstance item, GridCoord origin, Orientation orientation, out string reason)
  • bool TryAddQuantity(DefinitionId definitionId, int quantity, out string reason)
  • bool TryRemove(InstanceId instanceId, int quantityToRemove, out string reason)
  • bool TryMoveWithin(InstanceId instanceId, GridCoord newOrigin, out string reason)
  • bool TryMoveTo(InstanceId instanceId, InventoryRuntime target, int quantityToMove, GridCoord? targetOrigin, out string reason, Orientation orientation = Orientation.Normal)
  • bool TryRotate(InstanceId instanceId, out string reason)
  • bool CanFit(ItemInstance item, out string reason)
  • float CalculateTotalWeightKg()
  • void Sort()
  • event Action Changed

GridInventoryState

class

Cell occupancy and placement records for a spatial inventory. Low-level and mutation-only: it enforces geometry, never rules, cross-container moves, or reservation policy.

  • bool IsAreaFree(GridCoord origin, GridSize size, InstanceId ignoreInstance = default)
  • bool TryFindFirstFit(GridSize size, out GridCoord origin)
  • bool TryPlaceNew(ItemInstance item, GridCoord origin, GridSize baseFootprint, Orientation orientation, out string reason)
  • bool TryMoveAndRotate(InstanceId instanceId, GridCoord newOrigin, Orientation newOrientation, out string reason)
  • bool TryRotateInPlace(InstanceId instanceId, out string reason)
  • PlacedItem GetAt(GridCoord cell)
  • IEnumerable<PlacedItem> EnumeratePlaced()

BagInventoryState

class

Non-spatial backing store: a dictionary of instances with no placement at all. Capacity limits live in the shape descriptor's stack cap and in weight constraints, not here.

  • int Count
  • bool TryAdd(ItemInstance item, out string reason)
  • bool TryUpdate(ItemInstance updated, out string reason)
  • bool TryRemove(InstanceId instanceId, out string reason)
  • IEnumerable<ItemInstance> EnumerateItems()

InventoryReservationLedger

class

Per-instance soft locks. Every removal path checks it, so quantity promised to a pending shop sale or craft cannot be spent twice.

  • int GetReserved(InstanceId instanceId)
  • bool CanConsumeUnreserved(InstanceId instanceId, int availableQuantity, int requestedQuantity, out string reason)
  • bool TryReserve(InstanceId instanceId, int availableQuantity, int quantityToReserve, out string reason)
  • void Release(InstanceId instanceId, int quantityToRelease)
  • bool TryConsumeReserved(InstanceId instanceId, int availableQuantity, int quantityToConsume, out int remainingReserved, out string reason)

ItemInstance

class

One concrete item at runtime: a definition reference, a quantity, and per-instance state. Immutable, identity-equal, and engine-free, so it serialises through an explicit save schema without dragging a ScriptableObject along.

  • InstanceId InstanceId
  • DefinitionId DefinitionId
  • int Quantity, int MaxStackSize, bool IsStackable
  • float Condition01, bool IsFoundInRaid, bool IsPinned
  • string CustomName, TagColor, TagName
  • IItemStatePayload StatePayload
  • ItemInstance WithQuantity(int quantity) / WithCondition / WithPinned / WithTag / WithStatePayload

IItemStatePayload

interface

Marker for per-instance runtime state that travels with an item through storage. Carries only routing metadata; the concrete payload is a plain POCO owned by whichever package defines the stateful item.

  • string PayloadTypeId
  • int PayloadVersion

ItemStatePayloadRegistry

class

Thread-safe type-id to factory map the save layer uses to hydrate payloads. Registration is idempotent and overwrites, so editor hot reload does not throw. An unregistered id yields false instead of throwing, so an old save loads without the payload.

  • static void Register(string payloadTypeId, Func<IItemStatePayload> factory)
  • static bool TryCreate(string payloadTypeId, out IItemStatePayload payload)
  • static bool IsRegistered(string payloadTypeId)

InventoryShapeDescriptor

struct

The engine-free shape configuration produced from a shape definition: kind, grid size, force-one-by-one, allow-rotation, and bag stack cap. Grid and Bag factory methods cover the common cases.

  • InventoryShapeKind Kind
  • GridSize GridSize, int Columns, int Rows
  • bool ForceOneByOne, bool AllowRotation
  • int MaxStackCount
  • static InventoryShapeDescriptor Grid(int columns, int rows, bool allowRotation = true, bool forceOneByOne = false)
  • static InventoryShapeDescriptor Bag(int maxStackCount = 0)

PlacedItem

class

A grid placement record: the instance, its origin, its authored base footprint, and its orientation. EffectiveFootprint composes footprint and orientation on read, which is why rotation never touches the definition.

  • ItemInstance Item
  • GridCoord Origin
  • GridSize BaseFootprint
  • Orientation Orientation
  • GridSize EffectiveFootprint

IInventoryConstraint

interface

One accept-or-reject rule. Engine-free by design so rules run headless, and returns a reason string rather than a bool alone, because refusal messages are what the player actually reads.

  • bool CanAccept(ItemInstance item, InventoryContext context, out string reason)

InventoryContext

struct

Everything a constraint is allowed to know: the inventory's definition id, effective capabilities, shape, current weight excluding the candidate item, and a definition lookup. The struct carries everything a rule reads, so evaluation never touches the container and stays pure.

  • DefinitionId InventoryDefinitionId
  • InventoryCapabilities Capabilities
  • InventoryShapeDescriptor Shape
  • float CurrentWeightKg
  • IItemDefinitionLookup ItemDefinitions

IItemDefinitionLookup

interface

The engine-free view of item definition data used by rules and placement. UnityItemDefinitionLookup wraps an IDefinitionRegistry and projects ItemDefinition into the ItemDefinitionInfo snapshot.

  • bool TryGet(DefinitionId id, out ItemDefinitionInfo info)

InventoryComponent

component

Binds a definition to a GameObject, builds the definition lookup, owns an InventoryRuntime, and adds rule evaluation on top of every mutation. This is the type gameplay code holds a reference to.

  • InventoryRuntime Runtime
  • InventoryDefinition Definition
  • InventoryCapabilities EffectiveCapabilities
  • bool CanAccept(ItemInstance item, out string reason)
  • bool TryAdd(ItemInstance item, out string reason)
  • bool TryAddQuantity(DefinitionId definitionId, int quantity, out string reason)
  • bool TryMoveTo(InstanceId instanceId, InventoryComponent target, int quantityToMove, GridCoord? targetOrigin, out string reason, Orientation orientation = Orientation.Normal)
  • bool TrySplit(InstanceId instanceId, int quantityToSplit, out string reason)
  • void Configure(InventoryDefinition definition, GameDatabase databaseOverride = null)
  • event Action Changed

IInventoryService

service

Answers "where is the player's bag" so pickups, mission rewards, and debug menus do not perform a scene-wide component search on every grant. It stays thin: the component already exposes the full API.

  • InventoryComponent PlayerInventory
  • void RegisterPlayerInventory(InventoryComponent inventory)
  • bool TryAddToPlayer(ItemInstance item, out string reason)
  • bool TryAddQuantityToPlayer(DefinitionId itemDefinitionId, int quantity, out string reason)
  • event Action<InventoryComponent> PlayerInventoryRegistered

ContainerInventoryRegistry

class

Engine-free map from a container instance id to the InventoryRuntime holding its contents. Backs both wearable containers and world containers, with GetOrCreate taking a factory so hydration order does not matter.

  • bool TryGet(InstanceId containerInstanceId, out InventoryRuntime inventory)
  • InventoryRuntime GetOrCreate(InstanceId containerInstanceId, Func<InventoryRuntime> factory, out bool created)
  • bool Remove(InstanceId containerInstanceId)
  • IEnumerable<KeyValuePair<InstanceId, InventoryRuntime>> EnumerateAll()

ContainerItemInventoryService

component

Unity-side owner of the container registry. Creates a contents runtime on demand from a ContainerItemDefinition and hands it back for the lifetime of the container item instance.

  • ContainerInventoryRegistry Registry
  • bool TryGetContentsInventory(InstanceId containerItemInstanceId, out InventoryRuntime inventory)
  • InventoryRuntime GetOrCreateContentsInventory(ItemInstance containerItem, ContainerItemDefinition containerDef)
  • bool TryAddToContainer(ItemInstance containerItem, ContainerItemDefinition containerDef, ItemInstance payload, out string reason)

ItemInstanceFactory

class

Builds runtime instances from authoring assets without mutating them. CreateWithState forces MaxStackSize to 1 regardless of what the definition declares, because a stateful item cannot be fungible.

  • static ItemInstance Create(ItemDefinition definition, int quantity = 1, InstanceId? instanceId = null, string customName = null, float condition01 = 1f)
  • static ItemInstance CreateWithState(ItemDefinition definition, IItemStatePayload statePayload, InstanceId? instanceId = null, ...)

InventorySearchManager

component

Drives progressive reveal for search-gated containers, one coroutine per inventory, at a configurable seconds-per-item rate. Starting a search on an already-searching inventory is a no-op rather than a second coroutine.

  • bool IsSearching(InventoryRuntime inventory)
  • void StartSearch(InventoryRuntime inventory, Action onComplete = null)
  • void StopSearch(InventoryRuntime inventory)

ItemPickup

component

A world pickup carrying an ItemDefinition and a quantity, built on the interaction package's ZOAPickupBase and implementing IZOAInteractable. Grants publish through the static Granted event; the loot package's adapter is what routes them into the player's bag.

  • PickupPayload Payload
  • string Prompt
  • void SetPayload(ItemDefinition item, int quantity)
  • static event Action<PickupPayload, GameObject> Granted

ZOAInventorySceneInstaller

component

Scene-root bootstrap at execution order -9450. Registers an IInventoryService into FoundryServiceRegistry, defers to any pre-registered override, and unregisters on destroy only when the registered instance is the one it created.

  • IInventoryService Service
  • void EnsureRegistered()

ItemIconUtility

class

Resolves an item's icon and silhouette with deterministic fallbacks, generating and caching a placeholder sprite keyed by DefinitionId when nothing is authored. It deals only in Unity primitives, so it works for uGUI and UI Toolkit alike.

  • static Sprite ResolveIcon(ItemDefinition def)
  • static Sprite ResolveSilhouette(ItemDefinition def)
  • static Sprite GetOrCreateFallbackIcon(DefinitionId id, int sizePx = 64)

Surface

Authoring assets

ItemDefinition

asset

The authored item asset: icon, silhouette, optional 3D icon prefab, description, stack size, default grid footprint, weight, rarity, and category. Treated as immutable at runtime, with all mutable state living in ItemInstance. Create at Tools > ZOA > Inventory > Item Definition.

  • Sprite Icon, Sprite Silhouette, GameObject IconPrefab
  • int MaxStackSize, bool IsStackable
  • GridSize DefaultFootprint
  • float BaseWeightKg
  • ItemRarity Rarity, string Category, string Description

InventoryDefinition

asset

A container's authored configuration: which shape it uses, which rule set governs acceptance, its base capability flags, and an optional UI grouping hint. GetEffectiveCapabilities is the value consumers should read, since the shape can veto flags the author requested.

  • InventoryShapeDefinition ShapeDefinition
  • InventoryRuleSetDefinition RuleSet
  • InventoryCapabilities BaseCapabilities
  • string UiGroup
  • InventoryCapabilities GetEffectiveCapabilities()

GridShapeDefinition

asset

A general spatial grid with rows, columns, and a rotation toggle. Items occupy their authored footprint, so this is the shape behind the Tarkov-style backpack. Tools > ZOA > Inventory > Shapes > Grid Shape.

  • int Rows, int Columns
  • bool AllowRotation
  • override InventoryShapeDescriptor CreateDescriptor()

RpgShapeDefinition

asset

A grid that forces every footprint to 1x1 and forbids rotation, which is the classic slot-based RPG bag. It reuses the same grid machinery rather than introducing a separate code path, so ordering, moves, and stacking behave identically.

  • int Rows, int Columns
  • override InventoryShapeDescriptor CreateDescriptor()

BagShapeDefinition

asset

A non-spatial container with an optional maximum stack count, zero meaning unlimited. Use it for weight-limited or purely list-based inventories, and layer real capacity limits through weight constraints.

  • int MaxStackCount
  • override InventoryShapeDescriptor CreateDescriptor()

InventoryRuleSetDefinition

asset

A shareable, ordered list of constraint assets. Acceptance is the conjunction: the first constraint to refuse supplies the reason. Author one rule set per container archetype and reuse it across every inventory of that kind.

  • IReadOnlyList<InventoryConstraintBase> Constraints
  • bool CanAccept(ItemInstance item, InventoryContext context, out string reason)

InventoryConstraintBase

asset

Abstract ScriptableObject base for constraints. Shipped subclasses: AllowByTagConstraint, AllowByItemTypeConstraint, AllowByExplicitDefinitionConstraint, AllowByEquipmentSlotConstraint, and MaxWeightConstraint. Each is created under Tools > ZOA > Inventory > Constraints.

  • abstract bool CanAccept(ItemInstance item, InventoryContext context, out string reason)

ContainerItemDefinition

asset

An ItemDefinition that also owns an InventoryDefinition for its contents, which is how a backpack is both carried and openable. A nesting toggle lets a design forbid containers inside containers. Tools > ZOA > Inventory > Container Item Definition.

  • InventoryDefinition ContentsInventoryDefinition
  • bool DisallowNestedContainers

Usage

Examples

Building a runtime without Unitycsharp
using ZOA.Foundation.Core.Ids;
using ZOA.Foundation.Core.Inventory;
using ZOA.Inventory.Core.Inventories;
using ZOA.Inventory.Core.Items;

// A 10x8 grid that stacks, rotates, and tracks weight. No scene,
// no ScriptableObjects, no play mode: this runs under plain NUnit.
var runtime = new InventoryRuntime(
    DefinitionId.New(),
    InventoryShapeDescriptor.Grid(columns: 10, rows: 8, allowRotation: true),
    InventoryCapabilities.Stacking
        | InventoryCapabilities.Rotation
        | InventoryCapabilities.Weight
        | InventoryCapabilities.Reservations);

var rifle = new ItemInstance(InstanceId.New(), rifleDefId, quantity: 1, maxStackSize: 1);

if (!runtime.TryAdd(rifle, out var reason))
    Debug.LogWarning(reason);

// Placement at an explicit cell, rotated 90 degrees. A blocked origin
// fails outright rather than silently relocating the item.
runtime.TryAddAt(rifle, new GridCoord(row: 2, col: 0), Orientation.Rotated, out reason);
Definition metadata (footprint, weight, stack size) arrives through the optional IItemDefinitionLookup. Omit it in a test and every item is treated as 1x1 and weightless.
Checking before you offer the affordancecsharp
using ZOA.Inventory.Unity.Components;
using ZOA.Inventory.Unity.Factories;

public bool TryGive(InventoryComponent bag, ItemDefinition def, int count)
{
    var item = ItemInstanceFactory.Create(def, count);

    // Two separate gates, two separate messages. CanAccept is the rule
    // engine; CanFit is the geometry. Testing both up front lets the UI
    // say "your pack refuses grenades" rather than "it didn't work".
    if (!bag.CanAccept(item, out var ruleReason))
    {
        ShowToast(ruleReason);
        return false;
    }

    if (!bag.Runtime.CanFit(item, out var fitReason))
    {
        ShowToast(fitReason);
        return false;
    }

    return bag.TryAdd(item, out _);
}
Reserving quantity for a pending transactioncsharp
using ZOA.Foundation.Core.Ids;
using ZOA.Inventory.Unity.Components;

public sealed class ShopSale
{
    private readonly InventoryComponent _seller;
    private readonly InstanceId _stack;
    private readonly int _quantity;

    public bool Open()
    {
        // Soft lock: the stack stays visible and in place, but nothing
        // else can consume the reserved quantity while the sale is open.
        return _seller.TryReserve(_stack, _quantity, out _);
    }

    public bool Confirm()
        // Commit consumes the reservation as part of the removal, so the
        // availability check is not re-run against a value that may have
        // moved between Open and Confirm.
        => _seller.TryCommitReservedRemoval(_stack, _quantity, out _);

    public void Cancel() => _seller.ReleaseReservation(_stack, _quantity);
}
Registering a stateful item payloadcsharp
using ZOA.Inventory.Core.Items;
using ZOA.Inventory.Unity.Factories;

public sealed class MagazineState : IItemStatePayload
{
    public string PayloadTypeId => "mygame.weapon.magazine.v1";
    public int PayloadVersion => 1;

    public int RoundsRemaining;
}

// Once, at runtime boot. Registration is idempotent and overwrites,
// so a domain reload in the editor will not throw.
ItemStatePayloadRegistry.Register(
    "mygame.weapon.magazine.v1",
    () => new MagazineState());

// CreateWithState forces MaxStackSize to 1: a per-instance payload on
// a stack has no well-defined owner, so the factory refuses to make one.
var loaded = ItemInstanceFactory.CreateWithState(
    rifleDefinition,
    new MagazineState { RoundsRemaining = 30 });
The save layer routes on PayloadTypeId. An id it cannot resolve is skipped and the item hydrates without state, so an old save still loads after the package that wrote the payload is removed.
A custom constraintcsharp
using UnityEngine;
using ZOA.Inventory.Core.Items;
using ZOA.Inventory.Core.Rules;
using ZOA.Inventory.Unity.Rules.Constraints;

[CreateAssetMenu(menuName = "Tools/MyGame/Constraints/Min Condition")]
public sealed class MinConditionConstraint : InventoryConstraintBase
{
    [Range(0f, 1f)]
    [SerializeField] private float minimumCondition = 0.25f;

    public override bool CanAccept(ItemInstance item, InventoryContext context, out string reason)
    {
        if (item.Condition01 >= minimumCondition)
        {
            reason = null;
            return true;
        }

        // The reason string is what the player reads, so write it for them.
        reason = "This trader will not take badly damaged gear.";
        return false;
    }
}
Constraints see the container only through InventoryContext, so the same asset evaluates identically in a headless test and in a live scene.
Transferring between containerscsharp
using ZOA.Foundation.Core.Grid;
using ZOA.Inventory.Unity.Components;

// Whole stack, automatic placement in the target.
crate.TryMoveTo(itemId, playerBag, quantityToMove: int.MaxValue,
                targetOrigin: null, out var reason);

// Partial stack dropped on a specific cell, rotated. A partial move
// mints a new InstanceId for the moved portion and leaves the source
// stack behind with the remainder.
crate.TryMoveTo(itemId, playerBag, quantityToMove: 10,
                targetOrigin: new GridCoord(row: 0, col: 3), out reason,
                orientation: Orientation.Rotated);

Tooling

Editor tools

Item Definition Wizard

Tools > ZOA > Advanced > Define > Items > Item Definition Wizard

A four-step wizard on the shared FoundryWizardShell: Identity (name, description, category, rarity, tags), Visuals (icon, silhouette, icon prefab), Attributes (stack size, footprint, weight), and Summary (validation plus save path). Only the Summary step writes to disk. It supports browse-or-create, so an existing ItemDefinition can be loaded into the draft and edited rather than always starting fresh. Step order values are spaced by ten so a project can wedge its own step between the shipped ones.

Inventory Definition Wizard

Tools > ZOA > Advanced > Define > Items > Inventory Definition Wizard

Walks shape choice (Grid, RPG, Bag) with dimensions and rotation, then capabilities, then rule set and output path, and emits the shape asset, the rule set, and the InventoryDefinition together. It is a thin shell over IInventoryAuthoringService, so a generator or a test can produce the same assets headlessly.

Inventory Workbench module

ZOA Workbench > Inventory

Registered at order 125 under the items-and-economy workflow lane. Four capabilities: Items and Inventories browse, edit, validate, duplicate, and delete their respective definition assets; Rule Sets does the same for constraint sets; Inventory Browser lists every inventory definition with its configured shape, rules, and capabilities.

IInventoryAuthoringService

The headless authoring seam behind the wizard: BuildGridShape, BuildRuleSet, and BuildInventoryDefinition, each optionally writing to an asset path under the canonical layout. Implementations are required to produce output functionally identical to the wizard's, so a headlessly generated definition can be opened in the wizard afterwards without surprises.

ZOAIconBaker

Batch and per-item icon generation over Foundation's icon generator. It owns the output convention (Assets/ZOA/Generated/Icons), idempotence through a sidecar bakeinfo keyed on prefab GUID, prefab mtime, and a render-options hash, default render profiles per item archetype, and sprite assignment back onto the ItemDefinition through SerializedObject so inspectors and OnValidate observers see the change.

Read this

Notes and caveats

See also