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
ItemLicenceGatechecks 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.mddescribes 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 onePermissionSetwould 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.Hasdeliberately 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) —PurchaseAsyncrefuses an unknownlicenceIdoutright, a constraintPermissionSetwas 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 mirroringAccount.Moderation/AccountModeration), not a field flattened ontoCharacteror folded into an existing unrelated type just because the storage mechanics happen to overlap.CharacterLicencesfollows 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 IInventory — Inventory.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.RequestUseItem
→ ItemDefinition.CreateUseInteraction → Equippable.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:
- Validate. The licence id must be configured (
FindDefinition); the buyer must not already hold it;IEconomyService.LoadOrCreateAsyncthenCanAffordmust both pass. Any failure here leaves nothing mutated. - Grant, then persist, before debiting.
buyer.Licences.Grant(licenceId)mutates the in-memoryCharacter, thenICharacterService.SaveAsync(buyer)persists it — the same "mutate then explicitly save" conventionAdminModule.GrantAsyncalready uses forPermissions.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. - Debit. Only after the grant is durably saved does
IEconomyService.DebitAsyncrun. 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 rationalePropertyStore.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"). - Publish.
LicenceGrantedfires 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
CharacterProgressionalready keeps for biography/attributes/ skills (no independent tick/decay). A future fork wanting lapsing licences would add a timestamp field toCharacterLicences, followingAccountModeration.ExpiresAtUtc's precedent, not a redesign. - An admin free-grant command.
CharacterLicences.Grant/Revokeare public and idempotent, mirroringPermissionSet's exact shape, so a future/grantlicenceneeds no new plumbing — not built this pass sincePurchaseAsyncalone 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).ItemLicenceGateis deliberately general (ItemDefinitionin, notEquippable-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 deferralCode/Economy/README.mdalready documents forMoneyChanged. PoliceWeapons/IllegalWeaponsgating. See §4's own reasoning for why neither belongs behind a purchasable licence.