Generated reference. This page mirrors
Code/Vehicles/README.md, this module's source-of-truth documentation (Standards/02_DOCUMENTATION_STANDARDS.md §1). Edit it there, not here -- this file is regenerated on every docs build and any local edit is silently overwritten.
Vehicles¶
Purpose. The second ownable world entity, after World's Door. Sells vehicles through
the same purchase pattern PropertyStore.PurchaseAsync already established for doors --
Ownerships.md names this module explicitly as the eventual second consumer of the generic
Ownership claim. See
ADR-0009 for the full
reasoning (why a key item rather than a direct spawn, why Ownership rather than a dedicated
VehicleOwnership type) and
08_DATA_MODEL.md §10 for the
domain type's shape.
Responsibilities.
- Vehicle — OwnableId Id, DisplayName. Deliberately as thin as Door; see
08_DATA_MODEL.md §10 for why
its id is its Ownership.EntityId, not a separate id space.
- VehicleConfiguration — the operator-tunable purchase price (PurchasePrice, default
2000 -- no legacy precedent; the original manufactured a vehicle item through the Arms
Dealer job rather than selling at a fixed dealership price, see
Legacy/13_DEPENDENCY_GRAPH.md §2)
and interaction range (InteractionRange, default 150, same unverified-unit-scale caveat as
DoorConfiguration.InteractionRange), following
05_CONFIGURATION.md's per-module
GameResource pattern.
- IVehicleService / VehicleStore — registers a placed vehicle's persisted record
(RegisterAsync, load-or-create, called from VehicleComponent.OnAwake), and
PurchaseAsync: the same validate → assign-ownership → debit-money → publish-event
sequence, in the same order and for the same reason, as PropertyStore.PurchaseAsync. See
that method's own doc comment; not repeated here since it is the same decision, not a new
one. Key minting is no longer part of this sequence — PurchaseAsync publishes
VehiclePurchased and returns; it does not know VehicleKeyMinter exists.
- VehicleKeyMinter — subscribes HandleAsync to VehiclePurchased from
VehicleModule.RegisterServices (Documentation/Architecture/04_EVENT_BUS.md §7), minting
one ItemInstance into the buyer's inventory once ownership is assigned (ADR-0009). The
first real, first-party IEventBus subscriber in the codebase — proof that delivery works
through the actual shared, DI-resolved bus, not just a test's direct construction. Takes
only the collaborators minting needs (IItemRegistry, IInventoryService,
InventoryConfiguration, VehicleConfiguration, IGameLog), so it is constructible and
testable with no VehicleStore, no event bus, and no module-boot machinery at all — see
UnitTests/Vehicles/VehicleKeyMinterTests.cs.
- VehicleKeyBehaviour / VehicleKeyState — the ItemBehaviour/per-instance state pair
VehicleKeyMinter.HandleAsync uses to mint one ItemInstance into the buyer's inventory
once ownership is assigned (ADR-0009). Deliberately declared here, not in Code/Items/, so
Items never gains a dependency on Ownerships/Vehicles just to host a behaviour only one
consumer uses — see that file's own header comment. Assets/Items/Vehicles/vehicle_key.item
is the one authored ItemDefinition today, referenced by VehicleConfiguration.VehicleKeyItemId.
A missing/misconfigured id, a full inventory, or a failed inventory save are all logged as
operator-visible anomalies, never a reason to fail or roll back the purchase — the vehicle is
already genuinely the buyer's by the time minting runs, same reasoning as PurchaseAsync's
own debit-failure anomaly.
- VehicleComponent — the placed-in-map entity: [Property] VehicleId (generated once at
spawn, following DoorComponent.DoorId's precedent), [Sync(FromHost)] IsOwned /
OwnerName, and [Rpc.Host] RequestPurchaseVehicle() -- carries the same real distance
check as DoorComponent (CallerWithinRange, VehicleConfiguration.InteractionRange
against IPawnLocator.PositionOf). Since Milestone 12
(ADR-0012) also owns entering,
exiting, and driving -- see the dedicated subsection below.
- VehicleModule — registers IVehicleService and the VehicleDocument schema, and
subscribes VehicleKeyMinter.HandleAsync to VehiclePurchased. Depends on
InteractionModule since Milestone 12, for the same "reaching into a sibling component"
reason InventoryModule.cs already documents its own identical edge for.
Entering, exiting, and driving (Milestone 12)¶
See ADR-0012 for the full decision
and its Notes for two engine-API risks confirmed against ~/Projects/sbox-public during
implementation (GameObject.SetParent's existence/replication; the real limits of a
single suspend-movement bool).
- Entry is an
Interactionverb, not a bare RPC —VehicleComponent.RequestEnterVehicle(bool asDriver)builds anenter_vehicleInteractionRequest(VehicleConfiguration.EnterDuration, default 1.5s) and starts it on the caller's ownInteractionComponent.Runner, found viaIPawnLocator.Find→GameObject.Components.Get<InteractionComponent>(). AStillInRangecondition cancels the channel if the caller moves away mid-entry;OnCompleteis where the seat is actually granted and the pawn physically moved (below) — completing the interaction is not itself the authorization,VehicleStore.RequestEntryAsync's access check is. - Occupancy and access-eligibility resolve in
IVehicleService/VehicleStore, not in the engine-facing component — the same splitPurchaseAsyncalready established.RequestEntryAsync(Character, Vehicle, VehicleSeat)checksIOwnershipService.HasAccess(AccessLevel.Drivefor the driver seat,AccessLevel.Passenger— whichDrivealso satisfies — for the passenger seat), refuses an already-held seat or a caller already seated in a different vehicle, and is idempotent for a caller re-requesting a seat they already hold.Exit(Character, Vehicle)releases whichever seat they hold, a safe no-op otherwise.OccupancyOf(OwnableId)reads the current state. All three are unit-tested directly —UnitTests/Vehicles/VehicleStoreTests.cs. VehicleSeat(Driver/Passenger) andVehicleOccupancy(a small plainCharacterId?/CharacterId?pair withHolder/With/Withouthelpers) are new, engine-agnostic types —Code/Vehicles/VehicleSeat.cs, unit-tested on their own inUnitTests/Vehicles/VehicleOccupancyTests.cs. Occupancy is host-only, in-memory state insideVehicleStore, never persisted — exactly as ephemeral asIPawnLocator's own position lookup, not a property of the savedVehicle/Ownershiprecords.- Driving reparents the pawn's existing
GameObject;PawnComponent's identity never changes.VehicleComponent's entryOnCompletecalls the newPawnComponent.EnterVehicle(GameObject vehicle, Vector3 seatOffset)(SetParent+ local seat offset +IsInVehicle = true) andRequestExitVehiclecalls the newPawnComponent.ExitVehicle(Vector3 worldPosition)(SetParent(null, ...)+ world position +IsInVehicle = false) — both live inCode/Movement/PawnComponent.cs, not here, sincePawnComponentowns its ownGameObjectmutation surface (the same reasoningTeleport/SetNoclipalready established). The pawn stays registered withIPawnLocatorunder the same instance throughout — admin/goto//spectate//tpare undisturbed. DriverSeatOffset,PassengerSeatOffset,ExitOffsetare per-vehicle[Property]fields onVehicleComponent, author-tuned per prefab (different vehicle models need different seat positions) — notVehicleConfigurationserver-wide settings. Exit resolvesExitOffsetagainst the vehicle's current world transform at the moment of exit (GameObject.WorldTransform.PointToWorld(ExitOffset)), never the position the occupant entered from.DriverCharacterId/PassengerCharacterId([Sync(FromHost)] Guid, empty when the seat is empty) areVehicleComponent's own replicated snapshot ofIVehicleService.OccupancyOffor this specific vehicle — same relationshipIsOwned/OwnerNamealready have to the persistedOwnershiprecord, just for in-memory occupancy instead.- Scope for this pass: exactly one driver seat and one passenger seat per vehicle. A
disconnect/pawn-destroy while seated does not release the vehicle seat automatically (the
seat is only released by
RequestEntryAsync's own disconnect-during-channel guard, or by an explicitRequestExitVehiclecall) — see "Future improvements."
Not responsible for.
- Who owns a vehicle — Ownership's job; VehicleStore calls IOwnershipService, it does
not duplicate its logic.
- Moving money — Economy's job; VehicleStore calls IEconomyService.CanAfford/
DebitAsync, it never touches a balance directly.
- Granting access. AccessLevel.Drive/.Passenger are checked by the entry flow above,
never granted by anything in this module — IOwnershipService.GrantAccessAsync still has no
caller anywhere in Applejack.Vehicles. An owner always satisfies every AccessLevel
already, so this is only a gap for loaning a car to a second character. See "Future
improvements."
- Fuel/upkeep, an ownership cap, impound, or dealership UI — each its own smaller, independent
follow-on decision per ADR-0009's Notes.
Dependencies. Core (IServiceRegistry, IServiceResolver, IPersistenceBackend,
ISchemaRegistry, IEventBus, IGameLog), Character (Character,
ConnectionExtensions.GetCharacter()), Economy (IEconomyService), Ownership
(IOwnershipService, OwnableId, OwnerRef, AccessLevel), Movement (IPawnLocator, for the
distance check; PawnComponent.EnterVehicle/ExitVehicle for driving), Items
(IItemRegistry, ItemDefinition, ItemInstance, ItemBehaviour -- VehicleKeyBehaviour
extends it — used by VehicleKeyMinter, not VehicleStore), Inventories (IInventoryService,
InventoryConfiguration, CharacterInventoryHolder — the same collaborators
InventoryModule.GiveAsync itself uses, also VehicleKeyMinter's, not VehicleStore's),
Interaction (InteractionRequest, InteractionComponent, StillInRange — for
RequestEnterVehicle, Milestone 12), the S&box engine (Component, GameResource, Rpc,
Sync).
Public API.
- IVehicleService — RegisterAsync(OwnableId, string displayName), Find(OwnableId),
PurchaseAsync(Character buyer, Vehicle vehicle), RequestEntryAsync(Character, Vehicle,
VehicleSeat), Exit(Character, Vehicle), OccupancyOf(OwnableId).
- VehicleComponent — RequestPurchaseVehicle(), RequestEnterVehicle(bool asDriver),
RequestExitVehicle(), DriverSeatOffset/PassengerSeatOffset/ExitOffset,
DriverCharacterId/PassengerCharacterId.
- VehicleSeat, VehicleOccupancy.
- VehicleKeyMinter (internal) — HandleAsync(VehiclePurchased), subscribed by
VehicleModule.RegisterServices. Constructible directly for tests; no event bus required.
- VehicleKeyBehaviour, VehicleKeyState. VehicleKeyStateSerializer (internal) is
registered with IItemStateRegistry from VehicleModule.RegisterServices — without it the
minted key's VehicleKeyState is dropped on the next inventory save and the key stops
identifying its vehicle across a relog. See
09_ITEM_FRAMEWORK.md §2.1.
Events published.
- VehiclePurchasing — cancellable, published by VehicleStore once eligibility (unowned,
affordable) is confirmed but before ownership is assigned or money moves. Mirrors
Applejack.World.DoorPurchasing exactly.
- VehiclePurchased(Vehicle, Character) — published once ownership has been assigned. Mirrors
Applejack.World.DoorPurchased. VehicleKeyMinter.HandleAsync is the one subscriber today
(Documentation/Architecture/04_EVENT_BUS.md §7) — the first real, first-party IEventBus
subscription anywhere in the codebase.
- Entry/exit publish nothing of their own — IsInVehicle/DriverCharacterId/
PassengerCharacterId are [Sync] state, not events; no other module reacts to a vehicle
being entered yet.
Persistence. VehicleDocument { Id, DisplayName } under vehicles/{id}, schema version 1.
Ownership is a separate document under ownership/{entityId}, owned by the Ownership module
-- this module never writes it directly. The minted key ItemInstance lives in the buyer's own
inventories/{characterId} document (Inventories' persistence, unchanged by this module) --
no new persistence mechanism. Occupancy (Milestone 12) is not persisted at all — see the
Entering/exiting/driving subsection above.
Future improvements.
- Exercising Drive/Passenger grants via IOwnershipService.GrantAccessAsync -- entry now
checks AccessLevel, but nothing anywhere calls GrantAccessAsync to loan a car to a
second character yet; the owner's implicit full access covers solo use, not lending.
- A re-key mechanic for a lost/destroyed key with no matching AccessGrant left standing --
deliberately deferred, see ADR-0009's Notes.
- More than one passenger seat, or a general seat-count/seat-index system for vehicles that
should hold more than two occupants -- this pass supports exactly one driver and one
passenger. See ADR-0012's Notes.
- Releasing a seat automatically when its occupant's pawn is destroyed by a hard disconnect,
not just a graceful RequestExitVehicle call or a disconnect mid-entry-channel (both handled
today) -- the same class of cleanup gap Code/Movement/README.md's own "Future improvements"
already names for IPawnLocator.
- Fuel/upkeep as a recurring cost, an ownership cap, impound on theft/loss, and dealership UI.