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<ApplejackBootstrap>().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:
- 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 anItemInstance. 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 anItemInstance. - A pointer:
WeaponAttachmentStatestores anItemId, 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 sameItemInstanceat once. - Nesting (chosen): the attachment
ItemInstanceis removed from the owner's flat inventory and held inside the weapon's ownWeaponAttachmentState(Dictionary<AttachmentSlotKind, ItemInstance>), returned to the flat inventory on detach. This is not a new mechanism -- it isContainer's own "nesting is free" (08_DATA_MODEL.md §3) applied to a single nested item instead of a whole nested inventory.InventoryStore'sItemInstanceDocumentconversion already recurses generically (that is precisely what makes a nestedContainerround-trip through save/load today), so nesting one more item costs a second, much smallerIItemStateSerializer(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:
ParentIdresolves, or the asset fails to load (§1).- The referenced model resolves.
Categoryis a recognised value.MaximumStack > 0whenIsStackable; 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.