Skip to content

Licences

A player-earned, purchasable permit that gates carrying or using items of a restricted category. ROADMAP.md Milestone 14's own exit criterion names the shape directly: "a player can hold a licence that gates at least one existing check (e.g. carrying a weapon category)." Legacy never built anything in this space at all — Legacy/01_FEATURE_INVENTORY.md's Economy table marks Licences ❌, "never existed in legacy at all" — so this is genuinely new design, not a port. See Code/Licences/README.md.


1. What a licence is

A licence is a character-held permit, identified by a stable id, that a character either holds or does not. Holding one is what ItemLicenceGate checks before permitting a restricted action.

Deliberately narrow, the same discipline 14_NEEDS_FRAMEWORK.md §1 already applied to needs: this module owns "is it held" and "how is it acquired," nothing else. What a licence actually unlocks is a concern of whatever system reads ILicenceService/CharacterLicences — today that is one call site (§4), and nothing stops a second one from reusing the same catalog later.

2. Why a new type, not PermissionSet

Character already has a flat, exact-match, string-keyed grant set — PermissionSet, backing Character.HasPermission. A licence's storage shape (Grant/Revoke/Has over a HashSet<string>) is, mechanically, identical. The task this document answers explicitly poses the question of whether to reuse it — the answer is no, for reasons that matter more than the shape:

  • Provenance is opposite. Code/Characters/README.md describes permissions as "admin and gameplay permission grants" — in practice, every grant path in this codebase today (CharacterConfiguration.BootstrapPermissions, AdminModule's /grant//revoke) is an admin action, static until an admin changes it. A licence is the reverse: player-initiated, paid for out of the character's own balance (LicenceStore.PurchaseAsync, Economy), with no admin step in the normal path at all. Collapsing both into one PermissionSet would mean either admin tooling growing a "these are actually purchasable" special case, or every future licence needing an admin to manually grant it — neither is the right shape for either concept.
  • The catalog is data, not a fixed vocabulary. PermissionSet.Has deliberately checks against no registry at all — any string an admin types is a valid permission, by design (Code/Characters/README.md, "Not responsible for": "no wildcard to grant instead"). A licence, by contrast, has a real catalog with a cost and a meaning (LicenceDefinition/LicenceConfiguration, §3) — PurchaseAsync refuses an unknown licenceId outright, a constraint PermissionSet was never meant to enforce and deliberately doesn't.
  • Precedent already answers "where does it live," separately from "what type is it." ADR-0011 point 2 settled this shape for exactly this situation: new character-scoped state gets its own composed type alongside Character (Character.Progression/CharacterProgression, itself mirroring Account.Moderation/AccountModeration), not a field flattened onto Character or folded into an existing unrelated type just because the storage mechanics happen to overlap. CharacterLicences follows that precedent exactly — see §3.

CharacterLicences's implementation is intentionally boring — Held/Has/Grant/Revoke read almost line-for-line like PermissionSet, because "a flat set of exact-match string ids" genuinely is the right data structure for both. The type is still separate because what it means and who's allowed to change it are not the same, and a reviewer reading character.HasPermission("world.seal") next to character.HasLicence("firearms") should be able to tell, from the method name alone, that one is an admin grant and the other is something the player earned.

3. Schema

namespace Applejack.Characters;

/// <summary>
/// A character's held licences -- a flat set of granted licence ids, exact match only. See
/// this file's header comment and Documentation/Architecture/28_LICENCES.md §2 for why this
/// is a distinct concept from PermissionSet despite the similar shape.
/// </summary>
public sealed class CharacterLicences
{
    public IReadOnlyCollection<string> Held { get; }
    public bool Has(string? licenceId);
    public void Grant(string licenceId);
    public void Revoke(string licenceId);
}

Composed onto Character exactly the way CharacterProgression is:

public sealed class Character
{
    // ...
    public CharacterProgression Progression { get; init; } = new();
    public PermissionSet Permissions { get; init; } = new();
    public CharacterLicences Licences { get; init; } = new();

    public bool HasPermission(string permission) => Permissions.Has(permission);
    public bool HasLicence(string licenceId) => Licences.Has(licenceId);
}

The catalog side, content not code, follows NeedDefinition/NeedsConfiguration's exact shape (14_NEEDS_FRAMEWORK.md §1):

namespace Applejack.Licences;

public sealed class LicenceDefinition
{
    [Property] public string Id { get; set; } = "";
    [Property] public string DisplayName { get; set; } = "";
    [Property] public int Cost { get; set; }
    [Property] public List<ItemCategory> GatedCategories { get; set; } = [];
}

[AssetType(Name = "Licence Configuration", Extension = "licenceconfig", Category = "Applejack")]
public sealed class LicenceConfiguration : GameResource
{
    [Property] public List<LicenceDefinition> Definitions { get; set; } =
    [
        new() { Id = "firearms", DisplayName = "Firearms Licence", Cost = 500,
                GatedCategories = [ItemCategory.Weapons] },
    ];
}

PostLoad refuses a duplicate Id and refuses two definitions naming the same ItemCategory — every category has at most one owning licence, so RequiredLicenceFor (§5) never has to pick among ambiguous candidates.

Persistence. CharacterDocument bumps to schema version 4 (Code/Characters/CharacterStore.cs), adding a nested CharacterLicencesDocument { List<string> Held } alongside the existing Progression — the same "own composed type, own nested document" shape version 3 already established. CharacterDocumentV3ToV4Migration is an identity migration: the new field is additive with a harmless empty-list default, so a version-3 document deserialized directly into the current shape already has it correctly defaulted — a pre-Milestone-14 character simply holds no licences, the correct answer for a character that predates the concept entirely. No dedicated LicenceDocument/persistence key exists: a held licence is character-scoped state that loads and saves with the character, not on any schedule of its own — the exact reasoning CharacterProgression's own header comment already gives for why it isn't a NeedsStore-style separate store either.

4. What gets gated, and why: only carrying a weapon category

The roadmap's exit criterion names an example, not a mandate — "carrying a weapon category" is the worked case, and this module was built to satisfy it against a real, already-existing check, not a new parallel gate invented to look wired.

The search. Code/Items/ItemCategory.cs already has Weapons, PoliceWeapons, and IllegalWeapons as first-class category values — so "a weapon category" concept already exists; nothing needed inventing there. What doesn't exist anywhere in this codebase is a "carrying" state separate from an item simply sitting in an IInventoryInventory.Add (the only pickup path) performs a capacity check and nothing else; there is no permission gate on possessing an item today. The one real, already-wired, gameplay-meaningful action a restricted weapon item goes through is equipping: CharacterInventoryComponent.RequestUseItemItemDefinition.CreateUseInteractionEquippable.CreateUseInteraction's timed equip flow (09_ITEM_FRAMEWORK.md, 10_INTERACTION_FRAMEWORK.md). That is this codebase's actual model of "carrying" a weapon in the sense that matters to gameplay — holstered, ready to draw — not merely having one taking up inventory space. Gating that action is therefore the closest real fit named in the task brief's own fallback instruction ("an item pickup/equip check"), and it is what ItemLicenceGate does.

The gate itself, deliberately general, not weapon-specific:

namespace Applejack.Licences;

public static class ItemLicenceGate
{
    public static bool IsPermitted(
        ItemDefinition definition, Character character, ILicenceService licences,
        out string? missingLicenceId)
    {
        var required = licences.RequiredLicenceFor(definition.EffectiveCategory);

        if (required is null || character.HasLicence(required))
        {
            missingLicenceId = null;
            return true;
        }

        missingLicenceId = required;
        return false;
    }
}

It checks ItemDefinition.EffectiveCategory against ILicenceService.RequiredLicenceFor directly — not Equippable-specific, not weapon-specific. Any category a future LicenceDefinition names (e.g. an alcohol licence gating ItemCategory.Alcohol) is gated by the same check with zero code changes; the Firearms/Weapons pairing is this milestone's concrete instance, not a hardcoded special case. Categories with no configured licence (Food, Misc, …) always pass — RequiredLicenceFor returns null, and the check short- circuits before ever touching character.Licences.

Call site. CharacterInventoryComponent.RequestUseItem calls ItemLicenceGate.IsPermitted immediately after resolving the target ItemInstance and before handing anything to IInteractionRunner.TryStart:

if (!ItemLicenceGate.IsPermitted(instance.Definition, character, _licences, out var missingLicenceId))
{
    _log.Warning(LogCategory.Inventory, "use refused: caller does not hold the required licence",
        ("itemId", itemInstanceId), ("assetId", instance.Definition.AssetId), ("licenceId", missingLicenceId!));
    return;
}

ItemLicenceGate.IsPermitted itself is a plain static method — ItemDefinition/Character in, bool out, no GameObject/Component/Scene — precisely because Standards/03_TESTING_STANDARDS.md §1 requires the rule to live somewhere dotnet test can reach; RequestUseItem stays a thin wrapper that calls it, replicates nothing new, and is not itself unit-tested — the same split CharacterInventoryComponent.BuildSlots's own extraction already established for this exact file (11_UI_ARCHITECTURE.md §6).

Why not PoliceWeapons/IllegalWeapons too. LicenceConfiguration's seeded catalog gates only Weapons, deliberately: - PoliceWeapons is a job-loadout concept — once Jobs' per-job loadouts are real (16_JOBS_FRAMEWORK.md), that is the correct gate (holding the right job), not a licence a civilian could purchase their way into. Wiring a purchasable licence to police-only equipment would be actively wrong, not just premature. - IllegalWeapons is illegal by definition — no licence should ever legitimise carrying one; a "buy your way past the law" licence would contradict the category's own name and Crime's entire design (17_CRIME_FRAMEWORK.md).

Both are named here, not silently omitted, following this codebase's own documentation discipline for deliberate scope boundaries.

5. The purchase flow

LicenceStore.PurchaseAsync mirrors PropertyStore.PurchaseAsync's validate → assign → debit → publish ordering exactly (07_NETWORKING.md §7 traces the door version), for the same reason:

  1. Validate. The licence id must be configured (FindDefinition); the buyer must not already hold it; IEconomyService.LoadOrCreateAsync then CanAfford must both pass. Any failure here leaves nothing mutated.
  2. Grant, then persist, before debiting. buyer.Licences.Grant(licenceId) mutates the in-memory Character, then ICharacterService.SaveAsync(buyer) persists it — the same "mutate then explicitly save" convention AdminModule.GrantAsync already uses for Permissions.Grant. If the save fails, the in-memory grant is rolled back (Licences.Revoke) and the whole purchase fails: nothing happened, the buyer keeps their money, a safe abort.
  3. Debit. Only after the grant is durably saved does IEconomyService.DebitAsync run. If this fails, the licence is genuinely the buyer's — the anomaly is logged for an operator, never surfaced to the player as a failure. This is deliberately asymmetric with step 2: a failed grant-save means "nothing happened," but a failed debit-after-a-successful-grant means "the buyer keeps the licence without being charged" — the exact ordering rationale PropertyStore.PurchaseAsync's own header comment gives for why reversing this order would reproduce Legacy/13_DEPENDENCY_GRAPH.md §3's named legacy defect ("money gone, nothing owned").
  4. Publish. LicenceGranted fires once the whole sequence has succeeded.

Concurrency. _purchasesInFlight is a HashSet<(CharacterId, string)>, not HashSet<CharacterId> — keyed by buyer and licence id, so purchasing two different licences for the same character concurrently is legitimate and uncontended, while two concurrent purchases of the same licence for the same character are serialized, the same "checked-and-added synchronously before any await, removed in a finally" shape PropertyStore._purchasesInFlight already established.

6. Testing

CharacterLicences is plain and independently tested against PermissionSetTests.cs's exact shape. LicenceDefinition/LicenceConfiguration are GameResource-derived with no engine dependency in PostLoad, constructed and validated directly — NeedsConfigurationTests.cs's pattern. LicenceStore is tested against the shared InMemoryPersistenceBackend/real CharacterStore/EconomyStore fixtures, following PropertyStoreTests.cs's setup exactly, including the gated-fake-collaborator technique for exercising the in-flight concurrency guard deterministically. ItemLicenceGate.IsPermitted is tested as pure data-in/bool-out, no Scene required — the direct target of Standards/03_TESTING_STANDARDS.md §1's binding constraint. CharacterInventoryComponent itself is not unit-tested, for the same reason no other Component in this codebase is (excluded from OfflineTests, per that project's own .csproj comment) — its one line calling ItemLicenceGate.IsPermitted is reviewed by reading, matching every other [Rpc.Host] body's own verification story.

A schema migration test proves a version-3 CharacterDocument (pre-Licences) loads correctly into the version-4 shape with Licences.Held empty, per 06_PERSISTENCE.md's conformance requirement and this project's Definition of Done.

7. What this deliberately does not do

  • Licence expiry or renewal. A held licence is permanent once granted — the same deliberately minimal scope CharacterProgression already keeps for biography/attributes/ skills (no independent tick/decay). A future fork wanting lapsing licences would add a timestamp field to CharacterLicences, following AccountModeration.ExpiresAtUtc's precedent, not a redesign.
  • An admin free-grant command. CharacterLicences.Grant/Revoke are public and idempotent, mirroring PermissionSet's exact shape, so a future /grantlicence needs no new plumbing — not built this pass since PurchaseAsync alone satisfies the roadmap's "a way to purchase/grant a licence" build item.
  • A second gated check. The roadmap asks for "at least one" — this document names exactly one (equip, via RequestUseItem). ItemLicenceGate is deliberately general (ItemDefinition in, not Equippable-specific) so a second call site (e.g. a future Vendor refusing to sell a restricted item to a licence-less buyer, once Vendors exists) reuses it with no redesign — just not built until that consumer exists, the same deferral Code/Economy/README.md already documents for MoneyChanged.
  • PoliceWeapons/IllegalWeapons gating. See §4's own reasoning for why neither belongs behind a purchasable licence.