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.
CreateAsyncis the only writer ofCharacterSlotsunder this design. A remove-on-delete design would needDeleteAsyncto reconstructAccountwith 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
Characterrecords are never hard-deleted either (§2). - Every reader that cares about "currently selectable" already needs to open each slot's
Characterdocument anyway — to show a name, to checkIsDeleted— 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:
- Loads the caller's
Accountand refuses ifcharacterIdis not one of its ownCharacterSlots— the ownership check; a client cannot select another account's character by guessing or copying its guid. - Refuses if the caller already has an active character this session
(
GetActive(ownerAccountId) is not null) — mirrorsRequestCreateCharacter's existing guard, now checked against a growing slot list rather than an always-empty one, per ADR-0011 point 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);
- Ownership check, identical to
SelectAsync's. - Refuses if
characterIdis 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 forAccount.ActiveCharacterId. A player who wants to delete their active character callsClearActive/RequestReturnToSelection(§5) first, then deletes — one concept, reused, not duplicated. - Marks the character
IsDeleted = true,DeletedAt = IClock.UtcNow, and saves it. Does not mutateAccount.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.