Skip to content

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 ProgressionCharacterDocumentV3ToV4Migration 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. - ICharacterServiceCreateAsync, LoadAsync, SaveAsync, SelectAsync, DeleteAsync, GetSelectableCharactersAsync, ClearActive, GetActive, GetAllActive, FindActiveByName. - IAccountServiceLoadOrCreateAsync, 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). - CharacterConfigurationBootstrapAdministratorSteamIds, 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.