Generated reference. This page mirrors
Code/Characters/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.
Character¶
Purpose. The playable persona and the account above it. Everything a player perceives as "their character" is reached from here, either directly or through the module that owns that concern. See Documentation/Architecture/08_DATA_MODEL.md §1.
Responsibilities.
- Account / Character — the two concepts the original conflated into one Steam-keyed
table. See 08_DATA_MODEL.md §1.
- CharacterId — a strongly-typed id, not a bare Guid, so a CharacterId can never be
passed where an ItemId or OwnableId was meant.
- ICharacterService / CharacterStore — creation, load, save. Persists through
IPersistenceBackend under the characters/{id} collection
(08_DATA_MODEL.md §8).
CreateAsync guards against two concurrent calls for the same account (Milestone 9's
exploit-checklist pass), mirroring PropertyStore.PurchaseAsync's per-door in-flight set.
GetAllActive() (Milestone 7) exposes the same in-memory roster GetActive reads from, as
a snapshot, for anything that needs "everyone currently playing" — Chat's broadcast
channels are the first consumer.
- FindActiveByName(string) (Milestone 12) — a default interface method resolving "the
connected character named X" against GetAllActive()'s roster, with zero side effects.
Closes the gap 17_CRIME_FRAMEWORK.md §4
and 19_CHAT_FRAMEWORK.md §1
both named: LoadAsync marks its result active as a side effect (wrong for a pure lookup);
GetActive needs the target's SteamId, which a typed-name caller never has. Exact
(ordinal) match wins if unique; otherwise falls back to a case-insensitive match; more than
one match at either stage is a failure ("ambiguous"), never a silent pick — nothing enforces
unique character names. Code/Chat/ChatModule.cs's /pm is the first real consumer. Online
only — see "Not responsible for".
- CharacterConfiguration (Milestone 10) — the module's first GameResource configuration
(05_CONFIGURATION.md):
BootstrapAdministratorSteamIds lists the SteamID64s granted BootstrapPermissions on
every character create/load for that account. This is the only way a fork operator can
become an admin at all — nothing here invents a role/wildcard system;
BootstrapPermissions grants an explicit list because PermissionSet.Has is exact-match
only, by design. Applied on both CreateAsync and LoadAsync (an operator adding
themselves after their character already exists still gets the grant on their next login),
and only re-persists when something was actually newly granted.
- PermissionSet — a flat granted-permission-string model, engine-agnostic and
independently testable, per
Standards/03_TESTING_STANDARDS.md §3.
- CharacterNetworkComponent — the first real [Sync]/[Rpc.Host] surface, per
07_NETWORKING.md.
- CharacterModule — registers the above. CharacterTab (Code/Characters/Ui/
CharacterTab.razor, Milestone 11) is the player menu's Character tab — identity, editable
Details/Gender, and a faction list grouped by TeamGroup with a Become/Joined/Full button
per job, firing /team through CommandDispatchComponent
(24_PLAYER_MENU.md §5)
— but is NOT registered against IMenuRegistry: it's a PanelComponent (resolves the local
player's CharacterNetworkComponent/CommandDispatchComponent via
Applejack.Movement.LocalPawn), and that registry only accepts TPanel : Panel, new(). Its
composition into PlayerMenu is a still-open question — see Code/Ui/README.md's
"Panel-vs-PanelComponent" entry.
- CharacterCommandsModule (Milestone 11) — a sibling module, not a method on CharacterModule
itself: registers /details//gender, the player menu Character tab's self-service edits
(24_PLAYER_MENU.md §5).
Split out because CommandsModule already depends on CharacterModule
(Code/Commands/CommandsModule.cs) — CharacterModule depending back on CommandsModule to
register these itself would be a module dependency cycle. Same shape AdminModule already
uses relative to this module, for a different underlying reason.
- IAccountService / AccountStore (Milestone 11) — the first-ever Account persistence,
registered here rather than a separate AccountModule since Character already owns the
Account type. LoadOrCreateAsync follows OwnershipStore's shape, not CharacterStore's:
an Account has a natural 1:1 key (SteamId) that exists implicitly the moment its owner is
ever seen, so loading and creating collapse into one idempotent call rather than
CreateAsync/LoadAsync's separate, racy-if-merged pair.
- AccountModeration / ModerationStatus (Milestone 11) — a real reason/issuer/expiry record
(Status, Reason, IssuedBy, IssuedAtUtc, ExpiresAtUtc, IsActive(nowUtc)), replacing
the bare None/Warned/Banned enum this type used to be. IssuedAtUtc/ExpiresAtUtc are
DateTime, not DateTimeOffset — matching IClock.UtcNow's own return type
(Code/Core/IClock.cs), the same convention Code/Chat/ChatRouter.cs's cooldowns already
use, so IsActive needs no conversion at its call site.
CharacterNetworkComponent.RequestCreateCharacter checks IsActive at Banned before
calling ICharacterService.CreateAsync — the only real network entry point into character
creation/loading today (ICharacterService.LoadAsync has no RPC wired to it yet), so that is
where this enforcement actually sits, not also at "load" as first sketched. See
22_PLAYER_PRESENCE_AND_ADMIN_TOOLS.md §6
and Admin's /ban//unban.
Milestone 13 additions (creation, multiple characters, biography, attributes, skills). See
ADR-0011 and
25_CHARACTER_CREATION_AND_PROGRESSION.md
for the full design; summarised here:
- CharacterProgression (Character.Progression) -- Biography, Attributes, Skills
(Dictionary<string, int> each, deliberately open-ended -- Milestone 16 is the named first
real consumer, not designed yet).
- Character.IsDeleted/DeletedAt -- soft delete only, internal set, mirroring
IsInitialised's own discipline. CharacterId stays a valid foreign key forever.
- CharacterStore.CreateAsync now actually appends to Account.CharacterSlots (previously
declared, never written to), capped by the new CharacterConfiguration.MaxCharacterSlots
(default 3). The cap counts live (non-deleted) slots, not CharacterSlots.Count outright --
see 25_CHARACTER_CREATION_AND_PROGRESSION.md §3 for why CharacterSlots itself is append-only
and never shrunk on delete.
- ICharacterService.SelectAsync/DeleteAsync/GetSelectableCharactersAsync/ClearActive --
the full ownership/soft-delete/already-active exploit-checklist validation a real selection
flow needs now that an account can have more than one slot. CharacterNetworkComponent gains
RequestSelectCharacter/RequestDeleteCharacter/RequestReturnToSelection as thin RPC
wrappers around these, mirroring RequestCreateCharacter's existing shape.
- CharacterSelectPanel/CharacterSelectRootComponent (Code/Characters/Ui/) -- the real
creation/selection/deletion screen, shown automatically whenever the connection has no active
character.
- CharacterCommandsModule gains /biography <text...>. Attributes/Skills editing is
deliberately an admin action (AdminModule's new /setattribute//setskill), not player
self-service, until Milestone 16 decides how they're meant to be earned/spent -- see
25_CHARACTER_CREATION_AND_PROGRESSION.md §11 for the full reasoning.
- ADR-0011 compliance audit (every existing caller of ConnectionExtensions.GetCharacter()/
ICharacterService.GetActive/GetAllActive), performed this milestone:
- BulletinNetworkComponent, CommandDispatchComponent, CharacterInventoryComponent
(RequestUseItem/RequestAttachItem/RequestDetachItem), VehicleComponent,
DoorComponent, LockableContainerComponent all resolve Rpc.Caller.GetCharacter() fresh,
per RPC call, from the live session roster -- correct by construction: a stale reference is
structurally impossible when nothing ever caches the result across calls.
- Found and fixed: CharacterInventoryComponent.OnUpdate cached its loaded
CharacterInventoryHolder keyed by _loadingFor/_holder is null, but never re-checked
whether the replicated CharacterId had changed out from under an already-loaded holder --
exactly the "still-connected client swaps its active character mid-session" scenario ADR-0011
point 3 named as the real audit target, structurally impossible before this milestone (there
was only ever one character, ever, per account) and now real once selection/deletion/
reselection exist. Fixed to reload whenever the replicated CharacterId no longer matches
the loaded holder's own CharacterId, clearing the stale holder immediately rather than
displaying the previous character's inventory during the reload window.
- AdminModule/AdminPanel/AdminToolsRootComponent/PlayerListPanel, ChatRouter,
LawsTab, IPawnLocator's own doc comment -- all read GetAllActive() fresh on each use
(a live collection snapshot, or a razor panel re-evaluating on render), with no
per-CharacterId caching of their own. Confirmed correct as-is; no changes needed.
- AdminModule.BanAsync/MuteAsync/ClearModerationAsync's three Account.CharacterSlots
pass-through sites (reconstructing Account to update Moderation) carry the field through
opaquely and never interpret it -- confirmed unaffected by slots now actually being
populated or containing soft-deleted entries.
- PawnComponent.CharacterId/pawn-to-character binding remains a separate, already-tracked,
pre-existing gap (Code/Movement/PlayerSpawnerComponent.cs's own header comment) -- not
introduced or worsened by this milestone, and out of this milestone's scope to close.
Milestone 14 additions (Licences). See
28_LICENCES.md for the full design;
summarised here:
- CharacterLicences (Character.Licences) — a flat, exact-match set of held licence ids,
the same shape PermissionSet already has (Held, Has, Grant, Revoke), but a
deliberately distinct type: a licence is player-purchased, not admin-granted, and checked
against a real catalog (Applejack.Licences.ILicenceService), neither of which
PermissionSet is meant to do. See 28_LICENCES.md §2 for the full "why not just reuse
PermissionSet" reasoning. Character.HasLicence(string) mirrors HasPermission.
- CharacterDocument bumps to schema version 4, adding a nested Licences
(CharacterLicencesDocument) alongside Progression — CharacterDocumentV3ToV4Migration
is an identity migration, the same shape version 3 already established.
- Owned and granted by Licences, not here — this module only holds
the composed type and persists it, the same "Character owns the field, the other module
owns the logic" split Progression/Milestone 16 will eventually use too.
Not responsible for.
- Resolving an offline (disconnected) account's characters by name. FindActiveByName
only ever searches GetAllActive()'s roster — a genuinely different, larger problem needing
a persisted name index this codebase doesn't have, not just this in-memory lookup. See
Admin "Not responsible for" for where this blocks offline ban/mute.
- Money, inventory, needs, job — owned by Economy, Inventory, Needs, and Jobs respectively,
keyed by CharacterId. Character does not hold these as fields; see
08_DATA_MODEL.md §1, "One owner per piece of state".
- Team/gang membership and faction-scoped permissions — out of scope until the Teams/Jobs/
Factions module arrives (see
Legacy/05_TEAMS_JOBS_FACTIONS.md).
PermissionSet here is deliberately just a flat grant list, not a role/team resolver — no
team or gang concept exists in the codebase yet to resolve against.
- Cross-module "every dependent module's per-character setup is done" readiness gating.
Character.IsInitialised today only reflects Character's own load/spawn completing —
there are no other character-scoped modules yet to wait on. A real multi-module readiness
gate is Milestone 5+ scope, once Inventory or Economy actually need per-character setup at
spawn. See "Future improvements".
Dependencies. Core (IPersistenceBackend, ISchemaRegistry, IEventBus, IGameLog,
IClock — the last for CharacterNetworkComponent's ban check), Ui (IMenuRegistry, for
CharacterTab's registration). CharacterCommandsModule additionally depends on Commands
(ICommandRegistry).
Public API.
- Account, Character (Id, OwnerAccountId, Name, Details, Gender,
IsInitialised, IsDeleted, DeletedAt, Permissions, Progression, Licences),
CharacterProgression (Biography, Attributes, Skills), CharacterLicences (Held,
Has, Grant, Revoke), CharacterId, AccountModeration, ModerationStatus.
- ICharacterService — CreateAsync, LoadAsync, SaveAsync, SelectAsync, DeleteAsync,
GetSelectableCharactersAsync, ClearActive, GetActive, GetAllActive, FindActiveByName.
- IAccountService — LoadOrCreateAsync, SaveAsync.
- PermissionSet.
- CharacterTab — the player menu tab; not called directly by anything outside
IMenuRegistry's own instantiation. CharacterSelectPanel/CharacterSelectRootComponent —
the Milestone 13 creation/selection/deletion screen (§ above).
- CharacterConfiguration — BootstrapAdministratorSteamIds, BootstrapPermissions,
MaxCharacterSlots.
- /details <text...>, /gender <male|female>, /biography <text...> — self-service
commands, no permission required, registered by CharacterCommandsModule.
- CharacterNetworkComponent's RPCs — RequestCreateCharacter, RequestSelectCharacter,
RequestDeleteCharacter, RequestReturnToSelection.
- ConnectionExtensions.GetCharacter() — the Rpc.Caller.GetCharacter() lookup
07_NETWORKING.md assumes exists.
Events published. CharacterCreated, CharacterLoaded.
Persistence. characters/{id} — one document per character, schema version 4. Version 2
(Milestone 11) added Details/Gender, purely cosmetic fields for the player menu's
Character tab — CharacterDocumentV1ToV2Migration
(Code/Characters/CharacterStore.cs) is an identity migration, since both are additive with
a harmless empty-string default. Version 3 (Milestone 13) adds IsDeleted/DeletedAt and a
nested Progression (CharacterProgressionDocument) — CharacterDocumentV2ToV3Migration is
likewise an identity migration, per the same reasoning. Version 4 (Milestone 14) adds a nested
Licences (CharacterLicencesDocument) — CharacterDocumentV3ToV4Migration is likewise an
identity migration; see 28_LICENCES.md §3. See
08_DATA_MODEL.md §8,
24_PLAYER_MENU.md §5,
and 25_CHARACTER_CREATION_AND_PROGRESSION.md §8.
accounts/{steamId} (Milestone 11) — one document per account, schema version 1, via
AccountStore/IAccountService. AccountDocument.Moderation's shape changed from a bare enum
to the real AccountModeration record in the same milestone the schema was introduced — no
version bump, no migration, since nothing had ever persisted the old shape (this project has
never been opened in a real S&box editor; no live save data exists anywhere). /ban//unban
(Code/Admin/AdminModule.cs) are the first real writers. CharacterStore.CreateAsync is now
the first writer of Account.CharacterSlots (Milestone 13).
Future improvements.
- Resolving an offline account's character by name, once a persisted name index exists —
FindActiveByName only ever searches the in-memory active roster. Blocks offline ban/mute
(Admin "Not responsible for") and Milestone 17's fines/records
commands.
- Character.IsInitialised needs to become a real cross-module gate once a second
character-scoped module exists — see "Not responsible for" above.
- PermissionSet now has both: CharacterConfiguration's bootstrap list solves "how does the
very first admin get their first permission," and Admin's
/grant//revoke commands and AdminPanel (Milestone 10) solve "how does an existing
admin grant a permission to someone else," at runtime, with no restart. Character itself
still owns no admin UI or command — both live in Admin, which depends on Character, not the
reverse.
- A real "reject before the player even joins" enforcement point, if S&box ever confirms one
exists — today's ban check only refuses RequestCreateCharacter, since that is the only
real network entry point into character creation/loading (ICharacterService.LoadAsync has
no RPC wired to it at all yet).
- The Character↔pawn Transform binding this module has never had is
ADR-0007's decision — deliberately
landing in a new Applejack.Movement module, not here, to keep Character itself free of
any engine-typed field.