Skip to content

Item Framework

[KEY] — how an item is defined, and how it gets a behaviour. Builds on the definition shape in ADR-0004 and the instance shape in 08_DATA_MODEL.md §2; this document covers inheritance resolution, the behaviour system, and the load-time registry.


1. Inheritance, resolved explicitly

namespace Applejack.Items;

public sealed class ItemDefinition : GameResource
{
    /// <summary>
    /// The parent definition's id, or null. Resolved once at load -- see
    /// <see cref="ItemRegistry.ResolveInheritance"/>. Distinguishing "unset" from "set to a
    /// falsy value" is the entire point; see the D-04 note below.
    /// </summary>
    [Property] public string? ParentId { get; set; }

    // ... Name, Size, Cost, IsStackable, MaximumStack, Behaviours -- ADR-0004.
}
internal sealed class ItemRegistry : IItemRegistry
{
    /// <summary>
    /// Resolves every definition's inheritance chain once, at load. An unresolvable
    /// <see cref="ItemDefinition.ParentId"/> is a hard failure naming the asset -- never a
    /// silent empty merge. This is the direct fix for D-04, where the legacy loop tested
    /// self.Base but iterated self.base, so array inheritance silently did nothing.
    /// </summary>
    internal void ResolveInheritance(IReadOnlyList<ItemDefinition> definitions)
    {
        foreach (var definition in definitions)
        {
            if (definition.ParentId is null) continue; // unset -- no inheritance, and that's fine

            var parent = definitions.FirstOrDefault(d => d.AssetId == definition.ParentId)
                ?? throw new ItemLoadException(definition, $"parent '{definition.ParentId}' does not exist");

            Merge(definition, parent);
        }
    }

    /// <summary>
    /// A property explicitly set on <paramref name="child"/> is never overwritten by the
    /// parent's value, including when the child's value is false, zero, or empty -- the
    /// C# nullable/default distinction replaces Lua's single falsy concept and makes "unset"
    /// a real, checkable state instead of an assumption.
    /// </summary>
    private static void Merge(ItemDefinition child, ItemDefinition parent) { /* ... */ }
}

The compliance test named directly for this, per ADR-0004:

[TestMethod]
public void D04_ItemWithUnresolvableBaseFailsLoudly()
{
    var orphan = new ItemDefinitionFixture { ParentId = "does_not_exist" };
    Assert.ThrowsException<ItemLoadException>(() => Registry.ResolveInheritance([orphan]));
}

[TestMethod]
public void ExplicitFalseIsNotOverwrittenByParentsTrue()
{
    var parent = new ItemDefinitionFixture { AssetId = "base_container", IsStackable = true };
    var child = new ItemDefinitionFixture { ParentId = "base_container", IsStackable = false };

    Registry.ResolveInheritance([parent, child]);

    Assert.IsFalse(child.IsStackable); // not silently overwritten by the parent's true
}

2. Behaviour is composition, not inheritance

namespace Applejack.Items;

/// <summary>
/// A composable capability an item can have. An item is what it does, and it can do
/// several things -- an item can be Edible and a Container at once. See ADR-0004.
/// </summary>
public abstract class ItemBehaviour
{
    /// <summary>
    /// Builds the interaction this behaviour wants to run on use, or null if it has nothing
    /// to do for a plain "use". The caller passes the result to
    /// IInteractionRunner.TryStart -- see 10_INTERACTION_FRAMEWORK.md §2.1 and §3.
    /// `services` is the same resolver every Component reaches via
    /// `Scene.GetSystem&lt;ApplejackBootstrap&gt;().Services` -- a behaviour is plain data,
    /// deserialized from an asset, never constructed by dependency injection, so an
    /// `OnComplete` closure that needs a service (Edible's `INeedsService`) has no other way
    /// to reach one (Milestone 8).
    /// </summary>
    public virtual InteractionRequest? CreateUseInteraction(ItemInstance instance, Character user, IServiceResolver services) => null;
}

/// <summary>Restores a need on use. Legacy: items/food/*.lua's Hunger field.</summary>
public sealed class Edible : ItemBehaviour
{
    [Property] public string NeedId { get; set; } = "";
    [Property] public float RestoreAmount { get; set; }

    // Instant -- Duration is zero, so this completes synchronously inside TryStart rather
    // than occupying the actor's busy slot. See 10_INTERACTION_FRAMEWORK.md §2.1.
    public override InteractionRequest CreateUseInteraction(ItemInstance instance, Character user, IServiceResolver services) => new()
    {
        VerbId = "use",
        Actor = user,
        Target = new InteractionTarget(InteractionTargetKind.ItemInstance, instance.Id.Value),
        Duration = TimeSpan.Zero,
        Conditions = [],
        OnComplete = _ => { _ = services.GetRequiredService<INeedsService>().RestoreAsync(user.Id, NeedId, RestoreAmount); },
    };
}

/// <summary>
/// The timed equip flow. Legacy: items/base/weapon.lua's equip delay, traced in full in
/// Legacy/03_INVENTORY_AND_ITEMS.md §3.5. Gains attachment slots and jam state/logic at
/// Milestone 12 -- see §2.2 below for the full design reasoning.
/// </summary>
public sealed class Equippable : ItemBehaviour
{
    /// <summary>The holster slot this occupies -- `None` for a non-weapon (e.g. clothing),
    /// `Small` or `Large` for a weapon. The holster-count limit itself is configuration a
    /// future equip-tracking service owns, not this behaviour.</summary>
    [Property] public WeaponSlot Slot { get; set; } = WeaponSlot.None;

    /// <summary>Item id of the ammunition this needs to equip, or empty if none is required.</summary>
    [Property] public string RequiredAmmoItemId { get; set; } = "";

    [Property] public TimeSpan EquipDuration { get; set; }

    /// <summary>Which attachment slot kinds this weapon accepts -- empty means none. See §2.2.</summary>
    [Property] public List<AttachmentSlotKind> AttachmentSlots { get; set; } = [];

    /// <summary>This weapon's per-shot jam probability with nothing attached, in [0, 1]. See §2.2.</summary>
    [Property] public float BaseJamChance { get; set; }

    /// <summary>How long clearing a jam takes -- the "clear_jam" branch below. See §2.2.</summary>
    [Property] public TimeSpan UnjamDuration { get; set; }

    /// <summary>BaseJamChance plus every attached WeaponAttachment.JamChanceModifier,
    /// clamped to [0, 1]. See §2.2.</summary>
    public float EffectiveJamChance(WeaponAttachmentState? attachments) { /* ... */ return BaseJamChance; }

    // A genuinely timed interaction, unlike Edible's instant one. `Conditions` is empty and
    // the equip branch's `OnComplete` is a documented no-op today: the holster-slot-full
    // refusal and the "still standing still" cancellation both need systems that don't exist
    // yet (a weapon-holding/holster-count service, a position-change condition wired to a
    // live Transform) -- the timed shape itself is real. When this instance is currently
    // jammed, "use" means "clear the jam" instead -- see §2.2.
    public override InteractionRequest CreateUseInteraction(ItemInstance instance, Character user, IServiceResolver services)
    {
        instance.State.TryGet<WeaponJamState>(out var jam);

        if (jam is { IsJammed: true })
            return new()
            {
                VerbId = "clear_jam",
                Actor = user,
                Target = new InteractionTarget(InteractionTargetKind.ItemInstance, instance.Id.Value),
                Duration = UnjamDuration,
                Conditions = [],
                OnComplete = _ => jam.Clear(),
            };

        return new()
        {
            VerbId = "equip",
            Actor = user,
            Target = new InteractionTarget(InteractionTargetKind.ItemInstance, instance.Id.Value),
            Duration = EquipDuration,
            Conditions = [],
            OnComplete = _ => { /* give and select the weapon, decrement the holster count, once that service exists */ },
        };
    }
}

public enum WeaponSlot { None, Small, Large }

/// <summary>A physical attachment item -- a scope, a suppressor, an extended magazine. See §2.2.</summary>
public sealed class WeaponAttachment : ItemBehaviour
{
    [Property] public AttachmentSlotKind SlotKind { get; set; }
    [Property] public float JamChanceModifier { get; set; }
}

public enum AttachmentSlotKind { Sight, Muzzle, Magazine }

/// <summary>Holds an IInventory in the item's state bag. See 08_DATA_MODEL.md §2-3.</summary>
public sealed class Container : ItemBehaviour
{
    [Property] public int Capacity { get; set; }
}

Each behaviour is itself a small [Property]-bearing type, editable per-item in the inspector — a small_pocket.item and a large_backpack.item both use Container, differing only in Capacity. Bespoke, truly one-off behaviour needs a new ItemBehaviour subclass, which ADR-0004 names as the real cost of this design against the original's inline onUse callbacks — accepted, because the alternative reopens the arbitrary-code-execution surface the ADR exists to close.

Not built yet: Contraband. Legacy's illegal-goods items spawn a world entity and count against a contraband cap — Milestone 5 flagged this behaviour as deferred and it still is; no Code/Items/Contraband.cs exists. Assets/Items/README.md's Drugs/Contraband/IllegalGoods/ Explosives categories (Milestone 8) are migrated data-only for exactly this reason, each naming the specific missing system (Health, a non-lethal-force KnockOut, the Economy interval-income mechanism, a placeable/triggerable world entity) rather than a single generic "Contraband" stand-in.

2.1 Per-instance state, and how it persists

A behaviour is authored data shared by every instance; anything that differs between two instances of the same item lives in that instance's ItemStateBag, keyed by the state class itself. Container stores a ContainerState (its nested inventory); ADR-0009's vehicle key stores a VehicleKeyState (which vehicle it was cut for).

Persistence of that bag is a registration, not a branch:

namespace Applejack.Items;

public interface IItemStateSerializer
{
    string StateId { get; }        // stable id written into the save; never the class name
    Type StateType { get; }        // the ItemStateBag key
    int CurrentVersion { get; }    // per-state schema version
    string Serialize(object state);
    object? Deserialize(string payload, int version);   // null == drop this entry
}

Implementations derive from ItemStateSerializer<TState> (which supplies StateType and the casts) and register with IItemStateRegistry from the owning module's RegisterServices:

resolver.GetRequiredService<IItemStateRegistry>().Register(new VehicleKeyStateSerializer());

Two ids or two state types cannot collide — a duplicate registration throws, naming both claimants, for the same reason IServiceRegistry.AddSingleton does.

Why it works this way. InventoryStore originally hardcoded the single ContainerState case and wrote it to a dedicated ItemInstanceDocument.NestedInventory property. Every other state type was therefore dropped on save, silently. VehicleKeyState was the second state type ever added, and so became the first casualty: a minted vehicle key forgot which vehicle it opened the moment its owner relogged. A hardcoded branch per state type does not scale past one, and cannot work at all for a state type the framework does not ship.

Failure behaviour, deliberately asymmetric with the rest of persistence:

Case Result
Saving state with no registered serializer Entry dropped, warning logged, the item still saves
Loading state whose serializer is gone Entry dropped, warning logged, the rest of the inventory loads
Deserialize returns null (unreadable version) Entry dropped, warning logged

Never silent, and never fatal. One stale state entry must not make an entire inventory unloadable — the same reasoning 13_ERROR_HANDLING.md already applies to an item whose definition asset was deleted. This matters most for extensions: uninstalling a plugin should cost that plugin's state, not the player's bag.

2.2 Weapon attachments and jamming (Milestone 12)

ROADMAP.md Milestone 12 scopes this deliberately narrow: "extends the Equippable item behaviour... No new module." Legacy has nothing to preserve here -- Legacy/01_FEATURE_INVENTORY.md's Weapons table lists Attachments, jamming | ❌ | — | New outright, so this is genuinely new design work, not a port. Two questions had to be resolved for real rather than guessed at (both were live Milestone 12 spec questions, not settled architecture): what an attachment is, and how a jam is represented and cleared.

What an attachment is: a nested ItemInstance, not a pointer or a stat blob.

Three shapes were on the table:

  1. Flat stat modifiers baked into Equippable's own fields -- simplest, but dishonest about what an attachment is in a roleplay server: a scope is a physical object a player finds, holds, trades, and could plausibly drop, exactly like every other item this codebase models as an ItemInstance. Baking it into the weapon's fields would make attachments the one kind of physical object in the game that cannot be picked up, traded, or dropped on its own -- inconsistent with ADR-0004's whole premise that a physical thing is an ItemInstance.
  2. A pointer: WeaponAttachmentState stores an ItemId, and the referenced instance stays a separate entry in the owner's flat inventory. Rejected: it lets the same instance be simultaneously "attached" and independently tradeable/droppable/usable from the flat inventory -- a duplication bug waiting to happen, and this codebase is deliberately exploit-conscious (Documentation/Standards/03_TESTING_STANDARDS.md §5's exploit checklist). An attached scope and a loose scope in your bag must not be able to exist as the same ItemInstance at once.
  3. Nesting (chosen): the attachment ItemInstance is removed from the owner's flat inventory and held inside the weapon's own WeaponAttachmentState (Dictionary<AttachmentSlotKind, ItemInstance>), returned to the flat inventory on detach. This is not a new mechanism -- it is Container's own "nesting is free" (08_DATA_MODEL.md §3) applied to a single nested item instead of a whole nested inventory. InventoryStore's ItemInstanceDocument conversion already recurses generically (that is precisely what makes a nested Container round-trip through save/load today), so nesting one more item costs a second, much smaller IItemStateSerializer (Applejack.Inventories.WeaponAttachmentStateSerializer), not new recursion machinery.

WeaponAttachmentState itself lives in Applejack.Items, not beside ContainerState in Applejack.Inventories -- a deliberate, narrow deviation from that precedent. ContainerState must live in Inventories because it holds a real Inventory (an Inventories-owned type); WeaponAttachmentState holds only ItemInstance and AttachmentSlotKind, both already Items-owned, so nothing about it needs Applejack.Inventories at all. Only its serializer needs the store's nested-document conversion, so only the serializer lives there -- WeaponJamStateSerializer (below) makes the opposite point even more starkly: since its payload is a single bool with nothing to recurse through, it does not need Inventories either, and lives entirely in Applejack.Items, registered by ItemsModule itself.

Attaching and detaching are host-authoritative RPCs on CharacterInventoryComponent (RequestAttachItem, RequestDetachItem), not routed through the Interaction framework the way Equippable.CreateUseInteraction's "equip"/"clear_jam" verbs are. This was a deliberate choice, not an oversight: attaching a scope while it sits in your own bag has no legacy "stand still for N seconds" precedent to preserve (unlike the equip flow, Legacy/03_INVENTORY_AND_ITEMS.md §3.5), and forcing it through a timed interaction for this milestone would be scope creep against the roadmap's own "safe, independent, no new module" framing. A future pass is free to make attaching itself a timed Interaction (field-stripping a rifle taking real time is a reasonable future refinement) without changing WeaponAttachmentState's shape at all.

Why attachments live in "Weapons" content but affect only jam chance. WeaponAttachment exposes exactly one mechanical field, JamChanceModifier, because that is the only weapon stat this codebase has anything real to attach it to: no damage, recoil, noise, or accuracy system exists yet (this project has no bespoke shooting/combat module at all -- legacy fired through a real Source-engine SWEP, and nothing in the Roadmap replaces that with game logic this codebase owns). A suppressor that reduces noise, or a scope that affects accuracy, is a natural extension of WeaponAttachment the moment those systems exist -- not something to stub out speculatively today.

Jamming: plain state and logic, ready for a caller that does not exist yet. WeaponJamState is a small, engine-agnostic class -- bool IsJammed, AttemptFire(jamChance, roll), Clear() -- with no S&box type anywhere in it, fully covered by UnitTests/Items/WeaponJamStateTests.cs. This mirrors the *Store/*Component split Door/Vehicle/Character use for their engine-facing wrappers, scaled down to fit a single behaviour: the logic is real and tested; the "thin" half is that nothing in this codebase calls AttemptFire yet, because nothing in this codebase fires a weapon at all -- there is no shooting/combat module in the Roadmap for a jam roll to hook into. This is not a gap invented for this milestone: it is exactly the precedent Equippable.CreateUseInteraction's own equip-branch OnComplete already set (a documented no-op pending a weapon-holding service that also does not exist yet) -- "the shape is real, the wiring is pending" is this behaviour's established idiom, not a new one.

What is wired today is clearing a jam. Equippable.CreateUseInteraction reuses the single "use" hook every ItemBehaviour answers (ItemBehaviour.CreateUseInteraction) rather than adding a second verb-selection mechanism to the base class: when the instance's WeaponJamState.IsJammed is true, "use" means "clear_jam" instead of "equip". This keeps the milestone's own "no new module" framing honest at the framework level too -- no new extension point on ItemBehaviour, just a data-driven branch inside one behaviour's existing method, exactly the kind of change §2's "behaviour is composition, not inheritance" already anticipates a behaviour making internally.

AttemptFire takes (float jamChance, double roll) rather than owning a Random -- the roll is caller-supplied so the method is a pure function of its inputs, deterministic and directly testable, and so the eventual real caller can supply Random.Shared.NextDouble() without this type needing to change. An already-jammed weapon refuses AttemptFire outright rather than re-rolling -- you clear a jam, you do not roll past one -- which also makes the method safe for a future caller to call speculatively without first checking IsJammed itself.

3. Categories

public enum ItemCategory { Food, Drinks, Weapons, IllegalWeapons, IllegalGoods, Misc, /* ... */ }

Kept first-class because jobs gate manufacturing rights on category (Legacy/05_TEAMS_JOBS_FACTIONS.md §5), but expressed as an explicit [Property] on the definition rather than inferred from the containing directory the way the original did.

4. Load-time validation

Every definition is validated once, at load, and every failure names the specific asset:

  • ParentId resolves, or the asset fails to load (§1).
  • The referenced model resolves.
  • Category is a recognised value.
  • MaximumStack > 0 when IsStackable; a stack limit is meaningless otherwise.

This is GameResource.PostLoad(), the same mechanism 05_CONFIGURATION.md §4 uses — items and configuration are validated the same way because they are the same kind of thing, data an author edits without a compiler.

5. Hot reload

GameResource assets hot-reload in-game by engine default (Custom Assets). An item author changing Cost on cake.item sees the change take effect without a server restart. Existing ItemInstances hold a reference to their ItemDefinition, so a definition edit is visible immediately on every instance created from it — there is no per-instance copy to go stale.

6. Registry surface

namespace Applejack.Items;

public interface IItemRegistry
{
    ItemDefinition? Find(string assetId);
    ItemDefinition Get(string assetId); // throws if missing -- for call sites that already validated existence
    IReadOnlyList<ItemDefinition> All { get; }
    IReadOnlyList<ItemDefinition> InCategory(ItemCategory category);
}

Registered by ItemsModule in RegisterServices (02_MODULE_MODEL.md); every other module resolves items through this interface, never by loading GameResource assets directly — keeping inheritance resolution and validation in exactly one place.

7. Migrating legacy content

139 legacy items across 15 categories become content-authoring work once this framework exists, deliberately deferred — ADR-0004 names this explicitly. Item ids stay snake_case, matching the originals, so Legacy/03_INVENTORY_AND_ITEMS.md remains directly usable as the content specification when that work starts. See Guides/02_ADDING_AN_ITEM.md for the worked, no-code authoring flow.