Skip to content

ADR-0009 — Vehicles as a second Ownership consumer

Status: Accepted Date: 2026-07-29 Supersedes:Superseded by:


Context

Ownership was built generic on purpose. Code/Ownerships/README.md states its purpose as "one generic claim over an entity — who owns it, and who else may access it — applying uniformly to doors, vehicles, and any other ownable world object" and names its own gap explicitly under "Not responsible for": "What kind of entity is ownable, or what owning one lets you do (open a door, drive a vehicle) — that is each entity's own module (World, eventually Vehicles)." ADR-0006 and 08_DATA_MODEL.md §3 both already name vehicles as a future IInventoryHolder (trunk space), alongside characters and world containers, sharing the one inventory implementation. Nothing has built the Vehicles module either of those documents assumes will eventually exist — this ADR is that module's foundational decision, the same way ADR-0007 was Applejack.Movement's.

What the legacy game actually did. Legacy/13_DEPENDENCY_GRAPH.md §1–§2 identifies the vehicles plugin as one of only two engine-level Lua files (alongside sv_entity.lua) that genuinely diverged between the two legacy copies audited — everything else was content. The dependency graph records the mechanism precisely: items/base/vehicle.lua is an inventory item that delegates to the vehicles plugin, which "manufactures vehicle items" — i.e. a vehicle was purchased and held as an item, then turned into a world entity through the plugin, not bought and spawned directly. Legacy/04_ENTITIES_DOORS_PROPERTY.md §1 further confirms vehicles used the same generic _Owner table as doors (cider.entity.makeOwnable), just with no dedicated documentation chapter of their own — the original invested far less design into vehicles than into doors, jobs, or the economy.

What already exists to build on. The door purchase flow — DoorComponent.RequestPurchaseDoorPropertyStore.PurchaseAsync — is this codebase's one traced, reviewed purchase pattern (07_NETWORKING.md §7): load-or-create the buyer's economy and the entity's ownership, check unowned, check afford, assign ownership before debiting money (deliberately reproducing the safe half of the legacy failure mode named in 13_DEPENDENCY_GRAPH.md §3, never the unsafe half). Vehicles are the second thing this codebase will ever sell. Ownerships.md's own "Future improvements" already names a second AccessLevel as waiting on "a real consumer" beyond AccessLevel.Use — vehicles (which need to distinguish who may drive from who may merely ride or access the trunk) are plausibly that consumer.

What does not exist yet. No physical player presence existed anywhere in this codebase until ADR-0007's PawnComponent/IPawnLocator (Milestone 11, currently landing — see Code/Movement/). Actually entering and driving a vehicle is a pawn-to-pawn (or pawn-to-vehicle-entity) interaction with no precedent in this codebase at all; this ADR deliberately scopes itself to ownership, purchase, and inventory, leaving driving itself to a follow-on decision once Applejack.Movement has landed and been exercised by at least one real consumer (the admin teleport/spectate commands it was built for).

If we decide nothing, Vehicles stays the one module every existing document gestures at but none commits to, and any implementation attempt improvises its own purchase flow and access model instead of reusing the two that already exist.

Decision

Applejack.Vehicles is a new module, depending on Ownerships, Economy, Items, and Characters. A vehicle is sold as an instanced key item, not spawned directly on purchase: VehicleKeyBehaviour (an ItemDefinition behaviour, alongside Edible/Equippable/Container) stamps the purchased vehicle's OwnableId into the new ItemInstance's ItemStateBag at mint time. The vehicle's Ownership record is the single source of truth for who owns it and who may access it; the key is a physical, losable, giveable credential pointing at that record — never a second copy of the ownership data.

Concretely, mirroring DoorComponent/PropertyStore exactly:

namespace Applejack.Vehicles;

/// <summary>
/// One purchasable, ownable vehicle. See Documentation/Architecture/08_DATA_MODEL.md §3 (the
/// vehicle as IInventoryHolder for its trunk) and ADR-0009.
/// </summary>
public sealed class VehicleComponent : Component
{
    /// <summary>Generated once on first spawn -- the persistence key for this vehicle's
    /// Ownership record, mirroring DoorComponent.DoorId.</summary>
    [Property] public Guid VehicleId { get; set; }

    [Property] public string DisplayName { get; set; } = "Vehicle";

    [Sync(SyncFlags.FromHost)] public bool IsOwned { get; private set; }
    [Sync(SyncFlags.FromHost)] public string OwnerName { get; private set; } = "";

    // RequestPurchaseVehicle mirrors DoorComponent.RequestPurchaseDoor exactly: Identity from
    // Rpc.Caller.GetCharacter(), Distance from IPawnLocator (dealership lot, not "anywhere"),
    // Affordability and Concurrency delegated entirely to VehicleStore.PurchaseAsync.
    [Rpc.Host]
    public void RequestPurchaseVehicle() { /* delegates to IVehicleService.PurchaseAsync */ }
}

/// <summary>Per-instance state stamped onto a vehicle key ItemInstance at mint time. Empty
/// ItemStateBag entry name: "VehicleKey". See ADR-0006's ItemStateBag and ADR-0009.</summary>
public sealed class VehicleKeyState
{
    public required OwnableId VehicleId { get; init; }
}

IVehicleService.PurchaseAsync(Character buyer, VehicleComponent vehicle) follows PropertyStore.PurchaseAsync's exact sequence — load-or-create economy, load-or-create ownership, reject if already owned, check afford, assign ownership, then debit, then mint one ItemInstance from a VehicleKey ItemDefinition with VehicleKeyState.VehicleId set, into the buyer's inventory via the existing IInventory/InventoryStore — no new persistence mechanism, no new economy path.

Access. AccessLevel gains Drive and Passenger alongside the existing Use (Ownerships.md's named future work), and IOwnershipService.HasAccess takes an AccessLevel parameter rather than being a bare boolean. Giving someone your car is IOwnershipService.GrantAccessAsync(vehicleId, granteeCharacterId, AccessLevel.Drive) — the same generic mechanism a Team-owned door will eventually use, applied to a second consumer for the first time. Handing over the item (dropping the key, trading it) and handing over access are deliberately separate acts: losing your key without an access grant to match it still means the finder can enter and drive an unlocked vehicle, exactly as a real lost car key would — access, not possession of the key object, is the actual behavioural gate this ADR introduces, and matching the two is a UX affordance built on top, not a shortcut around it.

Explicitly out of scope for this ADR: entering/exiting/driving a vehicle (needs Applejack.Movement's PawnComponent, exercised by a real consumer first), fuel/upkeep as a recurring cost, an ownership cap or impound mechanic, and dealership UI. Each is a smaller, independent follow-on decision once this foundation exists — see Notes.

Alternatives considered

Purchase spawns the vehicle directly, no item indirection

The simpler mental model, and closer to how DoorComponent's purchase works today (a door is never "held," it's a fixed map object you unlock a claim on). Rejected as the sole mechanism because a vehicle, unlike a door, is legacy's own precedent for "ownable thing represented as an inventory item" (items/base/vehicle.lua) — losing that precedent would also lose, for free, everything ItemStateBag/instanced inventory already gives us: the key occupying scarce inventory space, being stealable, tradeable, and droppable with /dropmoney-style physicality, with zero new subsystem. A direct-spawn model would need its own answer to "how do I lend my car to a friend" that AccessGrant already answers for free once a key exists to carry the VehicleId around. This ADR chooses the key; a server operator who wants instant-spawn dealerships (no key, no loss risk) is free to build that as a separate, simpler Vehicles configuration later — this decision does not preclude it, it just isn't the default.

One dedicated VehicleOwnership type instead of reusing Ownership

Would let vehicle-specific fields (VIN, mileage, colour) live directly on the ownership record instead of split between Ownership and a separate Vehicle domain type. Rejected for the same reason Ownerships.md gives for why Ownership is generic at all: "this module has no idea what a door is" — duplicating that split per entity type (a VehicleOwnership, eventually a BoatOwnership) is exactly the "three-different-runtime-types-in-one-field" shape D-16 already named as a legacy defect, just inverted. Vehicle (VIN, model, colour) stays its own domain type in Applejack.Vehicles, holding an OwnableId the way Door already does — one more instance of a pattern already proven, not a new one.

AccessLevel.Use covers driving; no new enum members

Cheapest option — ship nothing new on Ownership. Rejected because a vehicle genuinely needs to distinguish "may drive" from "may ride along" from "may open the trunk," three different answers a door never had to give (a door is binary: you may open it or you may not). Reusing one flat Use for all three would force Vehicles to invent its own second access list alongside Ownership's, recreating exactly the "two systems tracking who's allowed to do what" seam Ownership exists to prevent.

Consequences

Positive

  • Reuses two already-reviewed patterns (PropertyStore.PurchaseAsync's purchase ordering, ItemStateBag's per-instance state) instead of inventing a third economy path or a third state-storage mechanism.
  • AccessLevel finally gains its named second member, and it's driven by a real caller (Vehicles) rather than spec'd speculatively ahead of one.
  • Vehicle theft, loaning, and resale all fall out of existing mechanics (item transfer, AccessGrant) instead of needing bespoke vehicle-specific plumbing.
  • Cleanly separable from the movement/pawn-binding work still landing — this module can be built and unit-tested under OfflineTests/ (ownership, economy, item state are all engine-agnostic) well before anyone can physically sit in a car.

Negative

  • Two sources of truth to keep synchronised in spirit, if not in data: the key item can be duplicated by a bug, traded to the wrong person, or destroyed independently of the Ownership record it points at. This ADR does not solve "what happens to the Ownership record if every copy of its key is lost" (see Notes) — an operator-visible anomaly, tracked the same honest way PropertyStore.PurchaseAsync's header comment already tracks its own rare debit-ordering anomaly.
  • A vehicle now depends on Items in addition to Ownerships/Economy/Characters — one more module edge than a door has, and one more thing Vehicles' own OfflineTests coverage has to exercise.
  • Splitting "possess the key" from "hold the access grant" is a real design subtlety a future contributor could easily miss and quietly conflate — flagged explicitly in Compliance below specifically so a reviewer checks for it.

Neutral

  • Applejack.Vehicles joins ApplejackBootstrap.Modules' fixed compile-time list, same as every other module (02_MODULE_MODEL.md §4).
  • Vehicle (VIN, model, colour) is a new domain type; Ownership/ItemDefinition/ ItemInstance gain no new required fields — vehicles fit the existing shapes rather than reshaping them.

Compliance

  • IVehicleService.PurchaseAsync's ordering (ownership-then-debit-then-mint) is reviewed against PropertyStore.PurchaseAsync's own header comment at implementation time — any divergence needs its own justification, not silent drift.
  • Every future [Rpc.Host] method this module adds is walked against the exploit checklist (Standards/03_TESTING_STANDARDS.md), same as every other RPC in this codebase.
  • Review checklist item for this module specifically: any PR that adds a feature checking "do they have the key item" as a stand-in for "do they have AccessLevel.Drive" (or vice versa) should be flagged — the two are deliberately separate per this ADR's Decision, and conflating them is the single most likely way this design gets quietly undone.

Notes

Open questions, deliberately deferred rather than decided here: - What happens when a vehicle's last key is destroyed or lost with no matching AccessGrant left standing — a re-key mechanic (e.g. via a dealership, for a fee), a permanent lock-out, or something else. Needs its own follow-on decision once ownership-cap/impound design (a separate flagged task) exists to interact with. - Whether VehicleKeyBehaviour should refuse to stack (almost certainly yes, mirroring any other unique/non-fungible item — ItemDefinition.IsStackable = false is already the default per ADR-0006) — not expected to be controversial, noted so it isn't silently forgotten. - Entering/exiting and actually driving a vehicle is intentionally not decided here — write a follow-on ADR once Applejack.Movement's PawnComponent has a second real consumer beyond admin teleport/spectate, per this ADR's Context section.

Revisit this when Jobs lands and Ownership's OwnerKind.Team/.Gang resolution gap closes (Ownerships.md's own named gap) — a team-owned squad car is a plausible, common case this ADR's AccessGrant-only answer will feel thin against once faction ownership actually resolves.