Skip to content

Character Creation and Progression

Milestone 13 (ROADMAP.md). Turns Account.CharacterSlots from a declared-but-unused field into a real, enforced multi-character model, and adds biography/attributes/skills. The cardinality, deletion, and "active" decisions here are ADR-0011's — this document is the implementation shape, not a second design pass.


1. CharacterProgression

namespace Applejack.Characters;

public sealed class CharacterProgression
{
    public string Biography { get; set; } = "";
    public Dictionary<string, int> Attributes { get; init; } = [];
    public Dictionary<string, int> Skills { get; init; } = [];
}

Composed onto Character as Character.Progression, mirroring AccountModeration's precedent of sitting beside its owning type rather than flattening in (ADR-0011 point 2). Biography is free text, the same "not a closed set" reasoning Character.Gender already uses. Attributes/Skills are deliberately a plain Dictionary<string, int> each, not a fixed enum-backed list — Milestone 16 (Crafting) is this ADR's own named first real consumer, and it has not been designed yet. Inventing a closed attribute/skill list now, with no consumer to validate it against, would just be guessing at Milestone 16's requirements a milestone early. The key is a caller-defined string identifier (e.g. "strength", "lockpicking"); the value is a plain int with no range enforced by this type — a future consumer defines what range makes sense for it, the same way PermissionSet enforces no shape on its own permission strings beyond "not empty."

Deliberately deferred to Milestone 16, not answered here (ADR-0011 Notes): - The exact attribute/skill list. - Whether attributes are fixed at character creation or grow over play. - Any mechanical consumer of these values at all — nothing in this codebase reads Attributes or Skills for a gameplay decision yet. Milestone 16's crafting success/speed check is the named first candidate.

2. Character gains IsDeleted/DeletedAt

public bool IsDeleted { get; internal set; }
public DateTime? DeletedAt { get; internal set; }

Explicit fields, the same style AccountModeration's own fields already use, rather than an implicit convention (e.g. inferring deletion from some other signal). internal set — only CharacterStore's own lifecycle flips these, mirroring IsInitialised's exact precedent directly above it on Character. DeletedAt is set from IClock.UtcNow, this codebase's one wall-clock source (Code/Core/IClock.cs), never DateTime.UtcNow directly — the same convention AccountModeration.IssuedAtUtc already established.

Never hard-deleted (ADR-0011 point 4). CharacterId stays a permanent, valid foreign key — Ownership, ICrimeService's warrants, and ban/mute records elsewhere in the codebase all reference a CharacterId directly and never consult IsDeleted themselves; a soft-deleted character's id keeps resolving to a real (if inert) record forever. No automatic hard-purge job exists or is proposed — out of scope for this milestone, the same deliberate deferral ADR-0011 itself names.

3. Account.CharacterSlots becomes append-only, and is the source of the cap

CharacterStore.CreateAsync now actually appends the newly created CharacterId to its owner's Account.CharacterSlots (load the account via IAccountService, append, save) — closing the gap ADR-0011 point 1 names: the field existed since Milestone 11 but nothing ever wrote to it.

Design decision: CharacterSlots is never shrunk on delete — soft-deleted entries are filtered by IsDeleted at read time, not removed from the persisted list. This was an open call ADR-0011 left for this implementation pass to settle, weighed as follows:

  • Append-only is a strictly smaller mutation surface. CreateAsync is the only writer of CharacterSlots under this design. A remove-on-delete design would need DeleteAsync to reconstruct Account with a filtered list too — a second writer of the same field, and a second place a bug in list maintenance could hide.
  • It preserves real history. An operator asking "how many characters has this account ever created" (a legitimate anti-abuse question — rapid create/delete/create cycling is exactly the kind of pattern worth being able to see) doesn't need a separate audit log; the list itself is the log, since Character records are never hard-deleted either (§2).
  • Every reader that cares about "currently selectable" already needs to open each slot's Character document anyway — to show a name, to check IsDeleted — so a second, redundant bookkeeping step to also keep the pointer list in sync buys nothing a read-time filter doesn't already give for free.

The cap in CharacterConfiguration.MaxCharacterSlots (§4) is therefore enforced by CreateAsync against the count of the account's live (non-deleted) slots, not CharacterSlots.Count outright — otherwise deleting a character would never free capacity for a new one, which would make deletion pointless as a slot-management tool. Counting live slots costs one persistence read per existing slot on every CreateAsync call; with a small default cap (§4) and this codebase's local JSON backend this is trivial. Named, not solved: because CharacterSlots never shrinks, this read cost grows with an account's total lifetime character count, not its live count, if an account repeatedly creates and deletes at the cap over a long period. No consumer of this codebase's own admin tooling makes that a real problem today; worth revisiting only if it ever does.

4. CharacterConfiguration.MaxCharacterSlots

[Property]
public int MaxCharacterSlots { get; set; } = 3;

Follows NeedsConfiguration's own GameResource/[Property] shape exactly (Code/Needs/NeedsConfiguration.cs). Default of 3: enough for a player to keep genuinely distinct personas (e.g. a civilian and an official character, plus one spare) without the per-CreateAsync live-slot count (§3) walking an unbounded list for a typical player, and small enough that an operator who wants more has an obvious, single number to raise. Validated > 0 in PostLoad, the same shape NeedsConfiguration's own validation uses.

5. Selection: ICharacterService.SelectAsync

Task<Result<Character>> SelectAsync(SteamId ownerAccountId, CharacterId characterId);

The full ownership/soft-delete/already-active exploit-checklist validation ADR-0011 point 3 calls for, layered in front of the existing LoadAsync primitive:

  1. Loads the caller's Account and refuses if characterId is not one of its own CharacterSlots — the ownership check; a client cannot select another account's character by guessing or copying its guid.
  2. Refuses if the caller already has an active character this session (GetActive(ownerAccountId) is not null) — mirrors RequestCreateCharacter's existing guard, now checked against a growing slot list rather than an always-empty one, per ADR-0011 point 3.
  3. Delegates to LoadAsync, which itself now refuses a soft-deleted character (§2) before registering it active.

A shared per-account in-flight guard (the same HashSet<SteamId> CreateAsync's own concurrency guard already used, broadened this milestone to cover both operations — see CharacterStore.cs's own comment) prevents two concurrent activation attempts — a create racing a select, or two selects — for the same account both succeeding and leaving _active in an unpredictable "whichever finished last" state.

Returning to selection. ICharacterService.ClearActive(SteamId ownerAccountId) is the explicit "return to selection" step ADR-0011 point 3 names as the prerequisite for calling selection a second time in one session. Purely in-memory (removes the account's entry from the same session-scoped roster GetActive/GetAllActive already read from) — there is nothing to persist, since "active" was never persisted in the first place (ADR-0011 point 1). Idempotent: clearing an already-clear account is a safe no-op.

6. Deletion: ICharacterService.DeleteAsync

Task<Result> DeleteAsync(SteamId ownerAccountId, CharacterId characterId);
  1. Ownership check, identical to SelectAsync's.
  2. Refuses if characterId is the caller's own currently-active character this session (GetActive(ownerAccountId)?.Id == characterId) — the chosen definition of "switched away first," settled here as the open call ADR-0011 point 4 left for this pass: "currently active this session" rather than e.g. a persisted flag, because "active" is already, deliberately, the one and only session-scoped concept this whole design revolves around (ADR-0011 point 1) — introducing a second, persisted notion of "which character is the player currently using" purely to gate deletion would be exactly the second source of truth ADR-0011 rejected for Account.ActiveCharacterId. A player who wants to delete their active character calls ClearActive/RequestReturnToSelection (§5) first, then deletes — one concept, reused, not duplicated.
  3. Marks the character IsDeleted = true, DeletedAt = IClock.UtcNow, and saves it. Does not mutate Account.CharacterSlots — see §3's reasoning for why the list is append-only.

Idempotent — deleting an already-deleted character re-saves the same IsDeleted = true state rather than failing a second time; there is no meaningful "double delete" error to surface.

A soft-deleted character can never end up active again by construction, not by an extra check: SelectAsync/LoadAsync already refuse a soft-deleted character (§2, §5), and DeleteAsync already refuses to delete the caller's own active one. The two rules combined mean GetAllActive() excludes soft-deleted characters automatically — there is no path that puts one into _active in the first place, so no separate filtering step is needed there.

7. Listing an account's selectable characters

Task<Result<IReadOnlyList<Character>>> GetSelectableCharactersAsync(SteamId ownerAccountId);

Loads the account, then each CharacterSlots entry's document (skipping soft-deleted and any that fail to load), and returns the rest — the read-time filter §3 relies on instead of shrinking CharacterSlots. This is what a character-select screen (§9) queries; it does not register anything active or publish any event, the same "pure lookup" discipline FindActiveByName's own doc comment already establishes for a listing operation.

8. Schema: CharacterDocument version 2 → 3

Adds IsDeleted, DeletedAt, and a nested CharacterProgressionDocument Progression (mirroring CharacterProgression's own composed-not-flattened shape, rather than three more flat top-level fields the way PermissionSet collapses to a flat List<string> — Progression's three fields describe one coherent concept, unlike Permissions, which really is just a flat set):

internal sealed class CharacterProgressionDocument
{
    public string Biography { get; set; } = "";
    public Dictionary<string, int> Attributes { get; set; } = [];
    public Dictionary<string, int> Skills { get; set; } = [];
}

CharacterDocumentV2ToV3Migration is an identity migration, the same shape CharacterDocumentV1ToV2Migration already established: every new field is additive with a harmless default (false, null, an empty CharacterProgressionDocument), so a version-2 document deserialized directly into the current shape already has them correctly defaulted — this migration exists only to satisfy SchemaRegistry's unbroken-chain requirement (06_PERSISTENCE.md §4).

9. UI: a real character-select screen

Code/Characters/Ui/CharacterSelectPanel.razor — an on-demand panel following AdminPanel.razor's shape (a live, host-side-only read of ICharacterService.GetSelectableCharactersAsync, the same 11_UI_ARCHITECTURE.md §7 carve-out every other on-demand panel in this codebase already uses). Lists the caller's own non-deleted characters with Select/Delete buttons, and a name field plus Create button when under the account's MaxCharacterSlots cap. Buttons fire the new RPCs (§10) through CharacterNetworkComponent directly — this is a session-establishing surface, not an ordinary command, so it does not go through CommandDispatchComponent.RequestExecuteCommand the way CharacterTab's existing Details/Gender edits do.

Code/Characters/Ui/CharacterSelectRootComponent.cs shows/hides the panel automatically by polling CharacterNetworkComponent.IsInitialised each frame (SelectPanel.Enabled = !characterNetwork.IsInitialised) — the panel is visible exactly when the connection has no active character, whether that's a fresh connection or the result of RequestReturnToSelection (§10) mid-session. This is a plain Component, not a keybind toggle like AdminToolsRootComponent — there is nothing to rebind; the screen's visibility is a direct function of session state, not a player-chosen toggle.

Named, not solved: the actual "spawn a pawn and drive it from the freshly selected character" wiring (PawnComponent.CharacterId binding — Code/Movement/PlayerSpawnerComponent.cs's own header comment already names this as still-open, pre-existing work, not something this milestone introduces or is expected to close) remains a separate, already-tracked gap. This screen's job ends at making ICharacterService's active-character roster correct; wiring a spawned pawn to whichever character that roster now names is Applejack.Movement's own still-open item.

10. New RPCs on CharacterNetworkComponent

[Rpc.Host] public void RequestSelectCharacter(Guid characterId);
[Rpc.Host] public void RequestDeleteCharacter(Guid characterId);
[Rpc.Host] public void RequestReturnToSelection();

Full exploit checklists live in CharacterNetworkComponent.cs itself, mirroring RequestCreateCharacter's existing comment-block format. RequestSelectCharacter additionally re-checks the caller's account ban status (AccountModeration.Status == ModerationStatus.Banned && IsActive) before delegating to SelectAsync — extended from RequestCreateCharacter's existing check, since a banned account should not be able to resume any of its characters, not just be blocked from creating a new one. RequestDeleteCharacter deliberately does not carry the same ban check: deleting your own character doesn't let a banned account resume play, so there is nothing for a ban to usefully block there.

11. Self-service editing: Biography, not Attributes/Skills

Code/Characters/CharacterCommandsModule.cs gains /biography <text...> (self-service, no permission, matching /details's own shape exactly).

Deliberate design decision, not an oversight: Attributes/Skills are not exposed as player self-service commands in this pass, even though CharacterTab displays them. Milestone 13's own open questions (ADR-0011 Notes) include "are attributes fixed at creation or do they grow over play" — that is unanswered specifically because there is no consumer yet to validate either answer against (Milestone 16). Letting a player free-type /setattribute strength 999 today, before any balance model or consumer exists, would quietly bake in "attributes are player-chosen text fields" as the de facto answer to a question this milestone explicitly defers — a worse outcome than just not building the self-service path yet. Instead, AdminModule gains /setattribute <characterId> <key> <value> and /setskill <characterId> <key> <value>, gated by admin.moderate like every other moderation write in that module — the same "an admin can set it by hand until a real system decides how it should work" precedent PermissionSet's own bootstrap-then-grant story already uses (§ Character README "Future improvements"). CharacterTab shows Attributes/Skills read-only for the character's own owner; editing them is an admin action until Milestone 16 gives it a real mechanic.

12. Testing

CharacterStore's cap enforcement, SelectAsync's ownership/soft-delete/already-active refusals, DeleteAsync's ownership/active-character refusal and idempotency, GetSelectableCharactersAsync's filtering, and the version 2 → 3 migration round-trip are all plain-data, engine-agnostic, and unit-tested directly against CharacterStore — no engine double needed, per Standards/03_TESTING_STANDARDS.md §1. CharacterNetworkComponent's new RPCs stay thin wrappers around these already-tested service methods (the same shape RequestCreateCharacter already uses), so the exploit-checklist guarantees they document are backed by real, testable logic even though the RPC surface itself is excluded from OfflineTests (Code/Characters/CharacterNetworkComponent.cs was already excluded before this milestone). CharacterSelectPanel.razor/CharacterSelectRootComponent.cs are reviewed by reading, the same untested-by-necessity treatment every other engine-facing panel in this codebase already gets.