Skip to content

Data Model

What a Character, an Item, a Container, a wallet, an Ownership claim, and a Door actually are — the shapes the rest of the framework operates on.


1. Account and Character

Two concepts the original conflated into one Steam-keyed table (Standards/01_NAMING.md §9):

namespace Applejack.Characters;

/// <summary>
/// The persistent Steam-level identity above characters. Owns account-wide state: bans,
/// playtime, and the character slots. Never carries gameplay state directly -- that lives
/// on the active Character. Legacy: ply.cider held both roles undivided.
/// </summary>
public sealed class Account
{
    public SteamId SteamId { get; init; }
    public IReadOnlyList<CharacterId> CharacterSlots { get; init; } = [];
    public AccountModeration Moderation { get; init; } = AccountModeration.None;
}

/// <summary>
/// A playable persona. Everything a player perceives as "their character" -- name,
/// appearance, money, inventory, job, needs -- is reached from here, either directly or
/// through the module that owns that concern (Economy owns Money, Inventory owns Inventory,
/// etc. -- see Documentation/Legacy/13_DEPENDENCY_GRAPH.md §5).
/// </summary>
public sealed class Character
{
    public CharacterId Id { get; init; }
    public SteamId OwnerAccountId { get; init; }
    public string Name { get; set; } = "";
    public bool IsInitialised { get; private set; }

    /// <summary>
    /// True once load, spawn, and every dependent module's per-character setup has
    /// completed. Interaction and RPC handlers must check this -- see the exploit checklist,
    /// Standards/03_TESTING_STANDARDS.md.
    /// </summary>

    /// <summary>Soft-delete markers -- never hard-deleted, CharacterId stays a valid foreign
    /// key forever. See ADR-0011 and 25_CHARACTER_CREATION_AND_PROGRESSION.md §2.</summary>
    public bool IsDeleted { get; private set; }
    public DateTime? DeletedAt { get; private set; }

    /// <summary>Biography/attributes/skills, composed rather than flattened -- see
    /// CharacterProgression's own doc comment and ADR-0011 point 2.</summary>
    public CharacterProgression Progression { get; init; } = new();
}

/// <summary>
/// Biography, attributes, and skills -- composed onto Character as Character.Progression,
/// mirroring AccountModeration's precedent of sitting beside its owner rather than flattening
/// in. Attributes/Skills are a deliberately open-ended Dictionary&lt;string, int&gt; each, not
/// a fixed enum-backed list -- Milestone 16 (Crafting) is the named first real consumer and
/// hasn't been designed yet. See 25_CHARACTER_CREATION_AND_PROGRESSION.md §1.
/// </summary>
public sealed class CharacterProgression
{
    public string Biography { get; set; } = "";
    public Dictionary<string, int> Attributes { get; init; } = [];
    public Dictionary<string, int> Skills { get; init; } = [];
}

Multiple characters per account (Milestone 13, ADR-0011). Account.CharacterSlots is populated by CharacterStore.CreateAsync (append-only -- a soft-deleted character's id stays in the list, filtered by IsDeleted at read time rather than removed), capped by CharacterConfiguration.MaxCharacterSlots. "Active" remains exactly the in-memory, session-scoped concept ICharacterService.GetActive/GetAllActive already were before this milestone -- there is still no persisted Account.ActiveCharacterId; a reconnecting player goes through selection again. See 25_CHARACTER_CREATION_AND_PROGRESSION.md for the full selection/deletion/progression shape.

One owner per piece of state. Character itself does not hold Money or Inventory as fields — those are owned by Economy and Inventory respectively, keyed by CharacterId, and reached through each module's service interface. This is the direct structural fix for Legacy/13_DEPENDENCY_GRAPH.md §5: ply.cider was a shared mutable bag eleven systems wrote to directly. Here, asking "what is this character's money" means calling IEconomyService.GetBalance(characterId), not reading a field.

2. Item definition and item instance

The template/instance split from ADR-0004 and ADR-0006, shown together because the relationship between them is the point:

namespace Applejack.Items;

// Full definition in ADR-0004 -- summarised here as the shape everything else references.
// Size/Cost/IsStackable/MaximumStack are nullable in the real type; see ADR-0004's
// "Implementation note" for why, and read Effective* (EffectiveSize, ...), never these raw
// properties, from outside Code/Items/ItemRegistry.cs.
[GameResource("Item", "item", "An Applejack item definition.", Icon = "inventory_2")]
public sealed class ItemDefinition : GameResource
{
    [Property] public string Name { get; set; } = "";
    [Property] public int? Size { get; set; }
    [Property] public int? Cost { get; set; }
    [Property] public bool? IsStackable { get; set; }
    [Property] public int? MaximumStack { get; set; }
    [Property] public List<ItemBehaviour> Behaviours { get; set; } = [];
}

/// <summary>
/// A specific item, in the world or in an inventory. See ADR-0006 -- this is what a flat
/// name-to-count map could never represent.
/// </summary>
public sealed class ItemInstance
{
    public ItemId Id { get; init; }
    public ItemDefinition Definition { get; init; } = null!;
    public int Count { get; set; } = 1;
    public ItemStateBag State { get; } = new();
}

ItemStateBag is a small typed dictionary keyed by the behaviour component that owns each piece of state — durability owned by a Durable behaviour, remaining ammunition by an Ammunition behaviour, and so on (09_ITEM_FRAMEWORK.md). An instance with no stateful behaviours has an empty bag, so the common case (a cake) costs nothing beyond an id and a definition reference.

3. Inventory and Container

One inventory model, used identically by characters, world containers, vehicles, and corpses (ADR-0006):

namespace Applejack.Inventories;

/// <summary>
/// A capacity-bounded collection of item instances. Implemented once; held by a character,
/// a world container, a vehicle trunk, or a corpse identically. See ADR-0006.
/// </summary>
public interface IInventory
{
    IReadOnlyList<ItemInstance> Contents { get; }

    /// <summary>The holder-specific capacity floor before any negative-size item's bonus.
    /// Exposed so IInventoryService can persist it without depending on the concrete
    /// Inventory type.</summary>
    int BaseCapacity { get; }

    /// <summary>
    /// Total capacity, in space -- not weight. Negative-size items (pockets) increase this.
    /// See Documentation/Legacy/03_INVENTORY_AND_ITEMS.md §1.2.
    /// </summary>
    int MaximumSpace { get; }
    int UsedSpace { get; }

    bool CanAccept(ItemDefinition definition, int count = 1);

    ItemInstance? Find(ItemId id);

    /// <summary>
    /// Adds a new instance. Merges into an existing compatible stack when
    /// <see cref="ItemDefinition.IsStackable"/> and room remains under
    /// <see cref="ItemDefinition.MaximumStack"/>; spills into additional stacks rather than
    /// silently dropping the remainder. Fails without mutating anything if it does not fit --
    /// see <see cref="CanAccept"/>.
    /// </summary>
    Result Add(ItemInstance instance);

    /// <summary>
    /// Removes <paramref name="count"/> units of the instance identified by <paramref name="id"/>
    /// and returns them as a distinct <see cref="ItemInstance"/> -- the original instance object
    /// if the whole entry was removed, or a freshly identified split if only part of a stack was
    /// taken (see ADR-0006 -- a stack's identity is the pile, not the units in it). The entry is
    /// deleted once its count reaches zero. Refuses, mutating nothing, if <paramref name="count"/>
    /// exceeds what is present, or if removing it would leave <see cref="UsedSpace"/> over the
    /// resulting <see cref="MaximumSpace"/> -- the pocket guard,
    /// Documentation/Legacy/03_INVENTORY_AND_ITEMS.md §1.2.
    /// </summary>
    Result<ItemInstance> Remove(ItemId id, int count);
}

/// <summary>
/// A holder of an inventory: a character, a world prop, a vehicle, a corpse. The holder
/// concept is deliberately thin -- it exists so ownership and interaction code can refer
/// to "whatever has an inventory" without caring which kind.
/// </summary>
public interface IInventoryHolder
{
    /// <summary>
    /// Persistence key for this holder's inventory, under the shared `inventories/{ownerId}`
    /// collection (§6) -- a <see cref="Character.Character"/>.Id.Value or a world container's
    /// own generated id. Both domains mint real GUIDs, so collision across them is not a
    /// realistic concern; no owner-kind tag is prepended.
    /// </summary>
    Guid OwnerId { get; }

    IInventory Inventory { get; }
}

Nesting is free: a container item's contents are simply an IInventory stored in its ItemStateBag, so a crate inside a locker works with no special case (ADR-0006). World containers (a filing cabinet, a locker bank) are IInventoryHolder components attached to a prop GameObject, sized per Legacy/03_INVENTORY_AND_ITEMS.md (a filing cabinet holds 20, a bank of lockers holds 100).

Transfers between any two IInventory are atomic — a failed transfer modifies neither side, the direct fix for the legacy flow where GiveMoney and setOwnerPlayer could partially fail (see the traced flow in 07_NETWORKING.md §7). There is no multi-document transaction available from IPersistenceBackend (§1), so IInventoryService.TransferAsync gets there by ordering, not by a real transaction: it computes both sides' next state as plain data first, persists the destination's new document, and only then persists the source's new document. In-memory Inventory objects are mutated only after both saves succeed. If the destination save fails, nothing was written and nothing is mutated. If the source save fails, the destination's on-disk document is transiently stale (it now names an item the source still, correctly, holds in memory) until that destination's Inventory is next saved for any reason, which happens naturally on its owner's own next mutation — an accepted trade-off, not a real transaction; see ADR-0006's Notes for the profiling-driven revisit condition this shares.

namespace Applejack.Inventories;

/// <summary>
/// Owns persistence and cross-inventory transfer for every <see cref="IInventory"/>,
/// regardless of holder kind. See §3 above and ADR-0006.
/// </summary>
public interface IInventoryService
{
    /// <summary>Loads the inventory for <paramref name="ownerId"/>, or creates an empty one
    /// sized to <paramref name="baseCapacity"/> if none has ever been saved.</summary>
    Task<Result<Inventory>> LoadOrCreateAsync(Guid ownerId, int baseCapacity);

    Task<Result> SaveAsync(Guid ownerId, IInventory inventory);

    /// <summary>Moves <paramref name="count"/> of <paramref name="itemId"/> from
    /// <paramref name="source"/> to <paramref name="destination"/>. See the atomicity note
    /// above; neither <paramref name="source"/> nor <paramref name="destination"/> is
    /// mutated if this returns a failure.</summary>
    Task<Result> TransferAsync(
        Guid sourceOwnerId, IInventory source,
        Guid destinationOwnerId, IInventory destination,
        ItemId itemId, int count);
}

4. Economy

namespace Applejack.Economy;

/// <summary>
/// A character's cash on hand -- an integer floored at zero, no debt, no interest. Preserves
/// the one invariant Documentation/Legacy/07_ECONOMY.md §1 calls out as worth keeping. Not a
/// field on Character -- see §1's "one owner per piece of state" -- reached only through
/// IEconomyService.
/// </summary>
internal sealed class EconomyDocument
{
    public Guid CharacterId { get; set; }
    public int Balance { get; set; }
}

There is deliberately no public Wallet/Money domain type here, unlike Character or ItemInstance — a character's balance is a single int, and IEconomyService.GetBalance returns it directly. EconomyDocument is internal, the persisted shape only EconomyStore touches, following CharacterDocument's precedent (Code/Characters/CharacterStore.cs's header comment) for exactly the same reason: nothing outside the store needs a settable, serialisable version of state the domain otherwise exposes read-only.

5. Ownership

namespace Applejack.Ownerships;

/// <summary>
/// A claim over an entity: who owns it, and who else may access it. Applies uniformly to
/// doors, vehicles, and any other ownable world object. See
/// Documentation/Legacy/04_ENTITIES_DOORS_PROPERTY.md.
/// </summary>
public sealed class Ownership
{
    public OwnableId EntityId { get; init; }
    public OwnerRef? Owner { get; set; }

    /// <summary>
    /// A denormalized snapshot of the owner's display name, captured once when
    /// <c>IOwnershipService.AssignOwnerAsync</c> runs -- not a live join against Character,
    /// Team, or Gang state, which this module cannot resolve generically across all three
    /// owner kinds. Mirrors the legacy `NWString cider_ownerName`
    /// (Documentation/Legacy/04_ENTITIES_DOORS_PROPERTY.md §1), itself a snapshot rather
    /// than a live lookup. Empty when <see cref="Owner"/> is null.
    /// </summary>
    public string OwnerDisplayName { get; set; } = "";

    public IReadOnlyList<AccessGrant> AccessList { get; set; } = [];
}

/// <summary>
/// An owner is a character, a team, or a gang -- never more than one of the three at once.
/// </summary>
public readonly record struct OwnerRef
{
    public OwnerKind Kind { get; init; }
    public Guid Id { get; init; }
}

public enum OwnerKind { Character, Team, Gang }

public readonly record struct AccessGrant(CharacterId Character, AccessLevel Level);

/// <summary>
/// How much an access-list entry grants, ordered from least to most privileged -- a grant at
/// any level implies every level below it (see <see cref="IOwnershipService.HasAccess"/>).
/// <see cref="Use"/> was the only member until ADR-0009 added the other two for Vehicles'
/// three-way distinction between who may drive, who may merely ride along, and who may reach
/// the trunk: <see cref="Use"/> (the original, generic "may access this thing" level -- for a
/// vehicle, trunk access) is the floor every grant satisfies; <see cref="Passenger"/> sits
/// above it; <see cref="Drive"/> is the ceiling. A door only ever grants or checks
/// <see cref="Use"/>, so this ordering is invisible to it -- it exists for Vehicles, the first
/// consumer that needs more than one rung.
/// </summary>
public enum AccessLevel { Use, Passenger, Drive }

Preserves the original's player/team/gang ownership model and access-list concept (Legacy/04_ENTITIES_DOORS_PROPERTY.md) as a gameplay decision worth keeping, per 00_CONSTITUTION.md §1 — but as a typed record with an explicit OwnerKind, not the bitfield described in D-16.

Access resolution today only covers what can actually be checked. IOwnershipService.HasAccess resolves a Character-kind Owner and explicit AccessGrant entries; a Team- or Gang-kind Owner cannot be resolved against real membership because the Jobs module (group/gang/team hierarchy) does not exist yet. OwnerKind.Team/.Gang are kept in the record now because the shape is right even though the resolution isn't, per Code/Ownerships/README.md's "Not responsible for."

HasAccess takes an AccessLevel now (ADR-0009). IOwnershipService.HasAccess(Ownership, CharacterId, AccessLevel) gained the third parameter alongside the two new members above: access is hierarchical, not a set of independent flags, so a caller asks "can this character do at least X" (a Drive grant satisfies a Passenger or Use check) rather than "does this character hold exactly X." The owner always satisfies every level, same as before this change. IOwnershipService.GrantAccessAsync(OwnableId, CharacterId, AccessLevel) is new alongside it -- the first real writer of AccessList (previously only AssignOwnerAsync ever touched an Ownership record). It replaces any existing grant for that character rather than stacking one per level, since a hierarchical level makes a second, lower grant for the same character meaningless. Revoke is still deferred -- no consumer needs it yet, per Code/Ownerships/README.md's "Future improvements."

6. Door

namespace Applejack.World;

/// <summary>
/// The one ownable world entity built this milestone
/// (Documentation/ROADMAP.md#milestone-6--first-playable--v050). Its own record is
/// deliberately thin -- everything about *who* owns it lives in Ownership, keyed by the same
/// <see cref="OwnableId"/>, per §1's "one owner per piece of state."
/// </summary>
public sealed class Door
{
    public OwnableId Id { get; init; }
    public string DisplayName { get; init; } = "";
}

A Door's id is its Ownership.EntityId — one id space, not a translation layer between a "door id" and an "ownable id," because a door has no existence as an ownable thing separate from the entity Ownership already tracks.

7. Relationships

erDiagram
    ACCOUNT ||--o{ CHARACTER : "owns 1..N slots"
    CHARACTER ||--|| INVENTORY : "has (via Inventory module)"
    CHARACTER ||--|| ECONOMY_DOCUMENT : "has a balance (via Economy module)"
    INVENTORY ||--o{ ITEM_INSTANCE : contains
    ITEM_INSTANCE }o--|| ITEM_DEFINITION : "instance of"
    ITEM_INSTANCE ||--o| INVENTORY : "may itself hold (container behaviour)"
    CHARACTER ||--o{ OWNERSHIP : "may hold as OwnerRef"
    TEAM ||--o{ OWNERSHIP : "may hold as OwnerRef"
    GANG ||--o{ OWNERSHIP : "may hold as OwnerRef"
    DOOR ||--|| OWNERSHIP : "owned via (same OwnableId)"

8. Persistence shape

Each type in this document is a SaveDocument<T> payload (06_PERSISTENCE.md §2) under its own collection: characters/{id}, inventories/{holderId}, economy/{characterId}, ownership/{entityId}, doors/{id}. They are saved separately, not as one nested character blob, specifically so that each schema changes migrate independently of the others — coupling every save to a single monolithic shape is how the original's implicit, hand-altered table schema (D-15) became unmanageable. The same one-collection-per- concern convention extends outside this document's own domain types too — bulletin/server (Code/Bulletin/README.md) was the first example of a module outside this data model persisting under it; needs/{characterId} (14_NEEDS_FRAMEWORK.md) is the second.

9. Testing

All types on this page are plain data with no engine dependency, satisfying Standards/03_TESTING_STANDARDS.md §1 directly. IInventory's capacity maths, Ownership's access resolution, IEconomyService's floor-at-zero arithmetic, and every schema's round-trip through IPersistenceBackend are unit-tested per Standards/03_TESTING_STANDARDS.md §3.

10. Vehicle

Added after this document's first pass, per ADR-0009 — see that ADR for the full reasoning (why a key item, why Ownership rather than a dedicated VehicleOwnership type). This section only fixes the shape.

namespace Applejack.Vehicles;

/// <summary>
/// The second ownable world entity, after Door (§6). Deliberately thin for the same reason --
/// everything about *who* owns it lives in Ownership, keyed by the same
/// <see cref="OwnableId"/>. See Documentation/ADR/0009-vehicles-as-ownership-consumer.md.
/// </summary>
public sealed class Vehicle
{
    public OwnableId Id { get; init; }
    public string DisplayName { get; init; } = "";
}

A Vehicle's id is its Ownership.EntityId, exactly as Door.Id already is — the same one id space, no translation layer, for the same reason given in §6. This section's shape (registration, the purchase flow, key minting, mirroring IPropertyService.PurchaseAsync exactly for the first two) was fixed by ADR-0009, which deliberately scoped entering and driving out until Applejack.Movement's PawnComponent (§1's sibling binding, ADR-0007) had a second real consumer.

That condition is now met: ADR-0012 (Milestone 12) adds entering, exiting, and driving on top of this same shape, with no change to Vehicle itself. Who currently occupies which seat (VehicleSeat, VehicleOccupancyCode/Vehicles/VehicleSeat.cs) is deliberately not part of this section's persisted shape — it is host-only, in-memory state tracked by VehicleStore, exactly as ephemeral as a pawn's own position (§1, ADR-0007's ephemeral IPawnLocator), never written to vehicles/{id} or any other document. VehicleComponent's own [Sync(FromHost)] DriverCharacterId/ PassengerCharacterId are a replication snapshot of that in-memory state, not a second copy of persisted data — the same relationship IsOwned/OwnerName already have to the (genuinely persisted) Ownership record.

The key is a credential, never a second copy of the ownership record. VehicleStore.PurchaseAsync mints one ItemInstance (§2) from a VehicleKeyBehaviour ItemDefinition once ownership is assigned, stamping VehicleKeyState.VehicleId (an OwnableId) into its ItemStateBag and adding it to the buyer's inventory (§3) — the same IInventoryService/CharacterInventoryHolder path /give already uses. VehicleKeyBehaviour/VehicleKeyState live in Applejack.Vehicles, not Applejack.Items, so Items never gains a dependency on Ownerships just to host a behaviour only one consumer needs — see Code/Vehicles/README.md. Losing the key and losing access (an AccessGrant, §5) are deliberately separate: the key only ever points at the real Ownership record, it is never consulted in place of IOwnershipService.HasAccess.

vehicles/{id} joins the collection list in §8. Vehicle joins §7's relationships the same way Door does: VEHICLE ||--|| OWNERSHIP : "owned via (same OwnableId)". The minted key itself adds no new collection — it is one more ItemInstance inside the buyer's existing inventories/{characterId} document (§3, §8).