Vendors¶
ROADMAP.md Milestone 14, one of four independent additions grouped into that milestone. The roadmap text is explicit about scope, and this document does not relitigate it:
Vendors / NPC shops — deliberately scoped as a static, ownerless shop entity reusing
WorldContainer's shape, no AI. Ships here, well before Milestone 18's NPC framework, on purpose — nothing about a shop actually requires a person behind the counter.
Everything below is in service of that one sentence. No AI, no pathing, no NPC behaviour is
built by this document or Code/Vendors/. That is
Milestone 18's job, and that milestone's
own sequencing note says explicitly that Vendors may be upgraded to NPC-driven once it lands
— "a pure enhancement, not a rebuild." Nothing in this design should make that upgrade harder
than it needs to be, but nothing here builds toward it either.
1. What gets reused from WorldContainer, and what doesn't¶
20_WORLD_LOCKS_AND_CONTAINERS.md §4
describes WorldContainer as "a placeable, registerable... world entity" and shows the shape
that recurs across Door, WorldContainer, and Vehicle:
- A plain identity
Guid, generated once at spawn and persisted with theGameObject. - A small domain record (
Door,WorldContainer,Vehicle) holding that id plus a handful of fields. - A service (
IPropertyService,IWorldContainerService,IVehicleService) withRegisterAsync(load-or-create, idempotent) andFind(in-memory lookup). - A thin
Componentthat generates the id, resolves services viaScene.GetSystem<ApplejackBootstrap>().Services, and fire-and-forgets the registration load inOnAwake.
Vendor/IVendorService/VendorStore/VendorComponent follow this shape exactly — see
Code/Vendors/Vendor.cs, IVendorService.cs, VendorStore.cs, VendorComponent.cs.
What is not reused is Ownership. Door and Vehicle are OwnableId consumers because
someone eventually owns them; WorldContainer carries OwnableId too, even though nothing
sells it yet (§5 of that document names this explicitly as "the one real gap this creates").
A vendor is never owned, by anyone, ever — the roadmap's "ownerless" is not a temporary gap
the way WorldContainer's is, it is the permanent, intended state. Vendor.Id is therefore a
plain Guid, and Applejack.Vendors has no dependency on Applejack.Ownerships at all:
namespace Applejack.Vendors;
public sealed class Vendor
{
public Guid Id { get; init; }
public string DisplayName { get; init; } = "";
public string CatalogueId { get; init; } = "";
}
2. Content: catalogues and listings, not a per-vendor [Property] List¶
A vendor needs to sell something, which is where this design genuinely departs from
WorldContainer (a container has no price list at all) and from Door/Vehicle
(a single server-wide PurchasePrice for the one thing they sell — themselves). A shop's
stock is inherently per-shop: a general store and a gun shop sell different things at
different prices.
The naive approach — a [Property] List<VendorListing> directly on VendorComponent — has no
precedent anywhere in this codebase. Every existing [Property] List<T> of a custom type is
declared on a GameResource (ItemDefinition.Behaviours : List<ItemBehaviour>,
JobDefinition.CanManufacture : List<ItemCategory>, NeedsConfiguration.Definitions :
List<NeedDefinition>), never on a plain Component. Introducing a new, unverified engine
shape (a Component holding a [Property] List<CustomClass>) for content that has a
perfectly good existing pattern to reuse is exactly the kind of unconfirmed-engine-assumption
risk this codebase avoids elsewhere (ItemDefinition.cs's own header comment is the clearest
statement of that discipline).
Instead, catalogues are authored content, following NeedsConfiguration's exact shape —
one GameResource asset per server, a List<NeedDefinition>-shaped list of plain
[Property]-bearing types, each with a string Id other code references by:
namespace Applejack.Vendors;
public sealed class VendorListing
{
[Property] public string ItemAssetId { get; set; } = "";
[Property] public int Price { get; set; }
}
public sealed class VendorCatalogue
{
[Property] public string Id { get; set; } = "";
[Property] public string DisplayName { get; set; } = "";
[Property] public List<VendorListing> Listings { get; set; } = [];
}
[AssetType(Name = "Vendor Configuration", Extension = "vendorconfig", Category = "Applejack")]
public sealed class VendorConfiguration : GameResource
{
[Property] public List<VendorCatalogue> Catalogues { get; set; } = [ /* seeded example */ ];
[Property] public float InteractionRange { get; set; } = 150f;
[Property] public TimeSpan PurchaseDuration { get; set; } = TimeSpan.FromSeconds(1);
}
VendorComponent.CatalogueId (a plain [Property] string, e.g. "general_store") is the
only per-placement authoring a content author does — the same "reference by string id,
resolve through a registry/lookup" convention VehicleConfiguration.VehicleKeyItemId already
established for cross-referencing an ItemDefinition, applied here to cross-referencing a
VendorCatalogue. ItemAssetId on VendorListing is resolved through IItemRegistry at
purchase time, never a direct ItemDefinition reference, for the identical reason.
This means placing ten general stores that all sell the same starting stock costs one authored catalogue, not ten. It also means an operator can rebalance every general store on the server by editing one asset, with no per-placement migration.
Deliberately not built this pass: finite, depletable stock. A Stock count on
VendorListing would need mutable per-vendor state persisted per listing (how many are left
at this specific vendor, since the catalogue itself is shared across every placement using
it) — real complexity for a feature the roadmap's exit criteria doesn't ask for ("a player can
... buy from a static vendor," not "... until it sells out"). Every listing is unlimited this
pass; see Code/Vendors/README.md's "Future improvements."
3. Buying is an Interaction, not a bare RPC — and why that's a deviation¶
DoorComponent.RequestPurchaseDoor and VehicleComponent.RequestPurchaseVehicle are both
bare [Rpc.Host] methods that validate and mutate atomically, with no channel at all. That is
the right shape for what they do: assigning ownership of a fixed, one-off entity, where there
is nothing to "browse" and the whole operation either fully happens or fully doesn't, in one
host tick.
Buying an item from a vendor is a different kind of action — repeatable (the same vendor can
be bought from over and over), and naturally suited to the same "timed, cancellable player
action" Interaction exists for
(10_INTERACTION_FRAMEWORK.md §1).
VehicleComponent.RequestEnterVehicle's "climbing in" channel is the closer precedent, not
RequestPurchaseVehicle's instant flip — see that method's own doc comment. VendorComponent
follows it directly:
[Rpc.Host]
public void RequestBuyItem(string itemAssetId)
{
var character = Rpc.Caller.GetCharacter();
if (character is null || !character.IsInitialised) return;
if (_vendor is null) return;
if (string.IsNullOrWhiteSpace(itemAssetId)) return;
if (!CallerWithinRange(character)) return;
var pawn = _pawns.Find(character.Id.Value);
if (pawn is null) return;
var interaction = pawn.GameObject.Components.Get<InteractionComponent>();
if (interaction is null) return;
var request = new InteractionRequest
{
VerbId = "buy_item",
Actor = character,
Target = new InteractionTarget(InteractionTargetKind.Entity, VendorId),
Duration = _config.PurchaseDuration,
Conditions = [new StillInRange(() => pawn.GameObject.WorldPosition, () => GameObject.WorldPosition, _config.InteractionRange)],
OnComplete = _ => _ = PurchaseAsync(character, itemAssetId),
};
var result = interaction.Runner.TryStart(request);
// ...
}
StillAlive is deliberately omitted, the same reasoning
VehicleComponent.RequestEnterVehicle's own Conditions gives: no health/incapacitation
system exists yet (Milestone 15). StillHasItem doesn't apply either — an ordinary purchase
requires no held item, only a sufficient balance, and that's re-checked authoritatively at
OnComplete, not as a running condition (a balance changing mid-channel because the buyer
sold something elsewhere isn't a "you moved away" style external interruption; it's exactly
the affordability check VendorStore.PurchaseAsync already re-runs on completion).
VendorConfiguration.PurchaseDuration defaults to one second — flavour ("browsing/haggling"),
not a real gate; zero is a valid operator choice for an instant purchase, per
§2.1,
and is deliberately not validated as positive for that reason (mirroring
VehicleConfiguration.EnterDuration's identical choice).
4. The purchase flow, and two deliberate deviations from PropertyStore.PurchaseAsync¶
IVendorService.PurchaseAsync(Character buyer, Vendor vendor, string itemAssetId) is modelled
directly on PropertyStore.PurchaseAsync/VehicleStore.PurchaseAsync — validate, publish a
cancellable "about to happen" event, mutate, publish a "happened" event, all inside one
in-flight-guarded method — but two things about it are deliberately different, both explained
in full in VendorStore.cs's own header comment and summarised here.
4.1 Ordering: grant the item before debiting, and why that's still the same principle¶
PropertyStore.PurchaseAsync's header comment establishes the ordering rule this codebase
uses for every purchase-style flow: do the "you get something real" side effect before the
"you get charged" side effect, so a failure partway through never produces "money gone,
nothing received" — the specific, named legacy defect
(Legacy/13_DEPENDENCY_GRAPH.md §3)
this codebase has now avoided twice (PropertyStore, VehicleStore) and avoids a third time
here:
- Resolve the vendor's catalogue and the requested listing; resolve the
ItemDefinition. CanAffordcheck.- Publish
VendorItemPurchasing(cancellable). - Grant the item to the buyer's inventory (
CharacterInventoryHolder.LoadOrCreateAsync+Inventory.Add+IInventoryService.SaveAsync). A full inventory, or a save failure, fails the whole purchase here — cleanly, with nothing charged. - Debit the price. A failure here is the same rare, logged, operator-visible anomaly
PropertyStore.PurchaseAsyncaccepts for the door-ownership case: the buyer keeps the item without being charged, which is a real but bounded cost, never the reverse. - Publish
VendorItemPurchased.
The one thing worth naming explicitly: this is not the same as
VehicleKeyMinter's shape, even though minting a vehicle key is superficially the same kind
of "give the buyer an item" step. VehicleKeyMinter runs decoupled, as a best-effort
VehiclePurchased event subscriber, because the vehicle is the purchase — the key is
incidental access tooling, so a failure to mint it is logged and moved past
(Code/Vehicles/README.md's own description: "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"). For a vendor, the sold item is the entire purchase — there
is no separate "real" thing being bought that the item is merely a receipt for. A failure to
grant it is the purchase failing, full stop, so VendorStore.PurchaseAsync grants the item
synchronously, inline, before the debit — not via an event subscriber.
4.2 The concurrency guard is keyed by buyer, not by the thing being purchased¶
PropertyStore/VehicleStore guard _purchasesInFlight per entity (the door, the
vehicle), because the scarce resource two concurrent callers could double-allocate is the
entity itself — a door can only ever have one owner, so the second of two concurrent
PurchaseAsync calls for the same door must lose.
A vendor's stock is unlimited this pass (§2), so two different buyers purchasing from the same
vendor at the same instant is not a race at all — both should simply succeed. What is a real
race: the same buyer firing two concurrent RequestBuyItem calls (from any vendor, not
necessarily the same one) before either has finished. Both could observe
CanAfford(price) == true before either has debited; since IEconomyService.DebitAsync
floors at zero rather than going negative, the second purchase would be effectively free — an
exploit Documentation/Standards/03_TESTING_STANDARDS.md's "call it twice concurrently"
checklist item exists specifically to catch. VendorStore therefore guards
HashSet<CharacterId> _purchasesInFlight, keyed by the buyer, not the vendor: at most one
purchase, from any vendor, is ever in flight per character at a time.
5. Events¶
public sealed class VendorItemPurchasing : CancellableEvent
{
public required Vendor Vendor { get; init; }
public required Character Buyer { get; init; }
public required ItemDefinition Item { get; init; }
}
public readonly record struct VendorItemPurchased(Vendor Vendor, Character Buyer, ItemDefinition Item, int Count) : IEvent;
Mirrors Applejack.World.DoorPurchasing/DoorPurchased exactly in shape and placement
(published after eligibility is confirmed but before the mutation; published again once the
mutation lands). VendorItemPurchasing exists for a concrete, named, not-yet-built consumer:
Milestone 14's own
sibling build item, Licences, gating a weapon-category listing behind a held licence, without
Applejack.Vendors ever depending on Applejack.Licences. Nothing subscribes to either event
yet — the same "extension point exists, no first consumer required to justify it" reasoning
DoorPurchasing itself demonstrates
(Guides/03_EXTENDING_THE_FRAMEWORK.md §3).
6. Persistence¶
VendorDocument { Id, DisplayName, CatalogueId } under vendors/{id}, schema version 1 —
the same shape as DoorDocument/WorldContainerDocument minus the lock/seal fields (a vendor
has no mutable state to persist beyond its own identity and which catalogue it sells from).
No ownership/{entityId} document is ever created for a vendor id. The granted ItemInstance
lives in the buyer's own inventories/{characterId} document — Inventories' existing
persistence, unchanged by this module, the same "no new persistence mechanism" outcome
VehicleKeyMinter's minted key already established.
7. Testing¶
VendorConfiguration.PostLoad (catalogue id uniqueness/non-empty, listing item-asset-id
uniqueness/non-empty within a catalogue, positive price/InteractionRange) has no test --
checked first: no GameResource.PostLoad override anywhere in this codebase has one either
(ItemDefinition, PersistenceConfiguration, DoorConfiguration, NeedsConfiguration).
PostLoad is engine-invoked only
(Code/Core/Configuration/ModuleConfigurationContext.cs's own comment), and nothing calls it
manually from a test anywhere in this codebase -- see Code/Needs/README.md's identical "Not
unit-tested" note for NeedsConfiguration. UnitTests/Vendors/VendorConfigurationTests.cs
instead covers FindCatalogue and the default seed, the same shape
NeedsConfigurationTests.cs uses for NeedsConfiguration.Definitions. VendorStore.PurchaseAsync is tested
against real EconomyStore/InventoryStore instances over the shared in-memory persistence
fixture, following PropertyStoreTests.cs's established precedent of a real collaborator over
a fake wherever one already exists and is already covered by its own suite — covering the
grant-before-debit ordering, the debit-fails-after-grant anomaly, the per-buyer (not
per-vendor) concurrency guard, an unknown/misconfigured catalogue, an item not sold at this
vendor, insufficient funds, and a VendorItemPurchasing veto. See UnitTests/Vendors/.
VendorComponent/VendorModule are excluded from OfflineTests for the same reason
VehicleComponent/VehicleModule are — they derive from Sandbox.Component/GameModule
with a MovementModule dependency, both unavailable in the engine-agnostic harness — and are
reviewed by reading, not compiled or tested there, the same as every other engine-facing
Component in this codebase.
8. What's deliberately not here¶
- AI, pathing, NPC behaviour. Milestone 18's job, named at the top of this document.
- Finite/depletable stock. §2.
- A client-facing shop/browsing UI.
RequestBuyItemrequires the caller to already know anItemAssetId. SeeCode/Vendors/README.md's "Future improvements" for the same "real backend, no UI yet" gap this codebase has already shipped elsewhere (Crime's fully tested, currently unreachableICrimeService;InventoryPanel's disabled registration). - Licence-gating. §5 names the extension point; nothing built by this milestone uses it.
- Buying more than one unit per request.
RequestBuyItemalways buys exactly 1.