Skip to content

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 the GameObject.
  • A small domain record (Door, WorldContainer, Vehicle) holding that id plus a handful of fields.
  • A service (IPropertyService, IWorldContainerService, IVehicleService) with RegisterAsync (load-or-create, idempotent) and Find (in-memory lookup).
  • A thin Component that generates the id, resolves services via Scene.GetSystem<ApplejackBootstrap>().Services, and fire-and-forgets the registration load in OnAwake.

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:

  1. Resolve the vendor's catalogue and the requested listing; resolve the ItemDefinition.
  2. CanAfford check.
  3. Publish VendorItemPurchasing (cancellable).
  4. 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.
  5. Debit the price. A failure here is the same rare, logged, operator-visible anomaly PropertyStore.PurchaseAsync accepts for the door-ownership case: the buyer keeps the item without being charged, which is a real but bounded cost, never the reverse.
  6. 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. RequestBuyItem requires the caller to already know an ItemAssetId. See Code/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 unreachable ICrimeService; 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. RequestBuyItem always buys exactly 1.