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<string, int> 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, VehicleOccupancy —
Code/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).