Player Menu¶
The generalized replacement for legacy's tabbed F1 frame
(legacy/Applejack/gamemode/derma/cl_menu.lua's cider_Menu). Composes two patterns that
already exist rather than inventing a third: IHudRegistry's registration-time decoupling
(11_UI_ARCHITECTURE.md §3) and the on-demand-panel
toggle AdminToolsRootComponent established
(11_UI_ARCHITECTURE.md §7). Visual
language is 23_UI_DESIGN_SYSTEM.md — not restated here.
1. IMenuRegistry¶
Mirrors IHudRegistry exactly — a module contributes a tab without PlayerMenu knowing that
module exists:
namespace Applejack.Ui;
public readonly record struct MenuTabRegistration(Type PanelType, string Title, string Icon, int Order);
public interface IMenuRegistry
{
/// <summary>Registers a tab panel type to be instantiated once the player menu builds.
/// Called from a gameplay module's own Ready -- the menu never needs to know
/// <typeparamref name="TPanel"/>'s type exists ahead of time. <paramref name="icon"/> is
/// one of the names §6 of 23_UI_DESIGN_SYSTEM.md still leaves open.</summary>
void RegisterMenuTab<TPanel>(string title, string icon, int order) where TPanel : Panel, new();
/// <summary>Every registration made so far, in ascending <c>Order</c>, read by
/// PlayerMenu.razor when it builds.</summary>
IReadOnlyList<MenuTabRegistration> RegisteredTabs { get; }
}
internal sealed class MenuRegistry : IMenuRegistry
{
private readonly List<MenuTabRegistration> _tabs = [];
public void RegisterMenuTab<TPanel>(string title, string icon, int order) where TPanel : Panel, new() =>
_tabs.Add(new MenuTabRegistration(typeof(TPanel), title, icon, order));
public IReadOnlyList<MenuTabRegistration> RegisteredTabs => [.. _tabs.OrderBy(t => t.Order)];
}
Registered by UiModule, alongside IHudRegistry — Code/Ui/ stays the one place both
registries live, since both exist for the same reason (§3's "the HUD/menu root stays ignorant
of which gameplay modules exist").
2. PlayerMenuRootComponent¶
Sibling to AdminToolsRootComponent, deliberately simpler — every player may open this menu,
so there is no permission gate to check, just a toggle:
namespace Applejack.Ui;
public sealed class PlayerMenuRootComponent : Component
{
[Property] public GameObject Menu { get; set; } = null!;
protected override void OnUpdate()
{
if (Input.Pressed("player_menu"))
Menu.Enabled = !Menu.Enabled;
}
}
One action name (player_menu), rebindable through S&box's own input-binding settings, same
as admin_panel/dev_menu. Default key is ` (backquote), set in
ProjectSettings/Input.config — F1 was the legacy convention but isn't carried over as the
default here.
3. PlayerMenu.razor¶
Builds a nav rail imperatively from IMenuRegistry.RegisteredTabs, the same
TypeLibrary.Create<Panel>(Type) mechanism HudRoot.razor already uses for a type only known
at runtime — but unlike HudRoot, only the selected tab's panel exists as a child at a
time, swapped on click rather than all being mounted simultaneously (the legacy DPropertySheet
kept every sheet alive at once; this codebase has no read yet needing that, and mounting only
the visible tab avoids paying every tab's BuildHash/query cost while hidden):
<root>
<div class="player-menu">
<div class="player-menu-rail">
@foreach (var tab in Tabs())
{
<div class="player-menu-rail-icon @(tab.Title == _selected ? "active" : "")"
onclick="@(() => Select(tab.Title))">
<i class="@tab.Icon" />
</div>
}
</div>
<div class="player-menu-content" @ref="_content" />
</div>
</root>
@code {
private string _selected = "";
private Panel? _content;
protected override int BuildHash() => _selected.GetHashCode();
private IReadOnlyList<MenuTabRegistration> Tabs() =>
Scene.GetSystem<ApplejackBootstrap>().Services!.GetRequiredService<IMenuRegistry>().RegisteredTabs;
protected override void OnAwake()
{
var tabs = Tabs();
if (tabs.Count > 0) Select(tabs[0].Title);
}
private void Select(string title)
{
_selected = title;
_content?.DeleteChildren();
var tab = Tabs().FirstOrDefault(t => t.Title == title);
if (tab.PanelType is not null)
_content?.AddChild(TypeLibrary.Create<Panel>(tab.PanelType));
}
}
ASSUMED, NOT VERIFIED THIS PASS (same caveat every panel in this codebase carries,
11_UI_ARCHITECTURE.md §7): @ref
capturing a child Panel reference the way this sketch assumes; Panel.DeleteChildren() as
the real method name for clearing a container before swapping in a new tab's panel. If either
assumption is wrong, HudRoot.razor's own "build every slot container in OnAwake, never
torn down" approach is the fallback — mount every tab once, toggle visibility instead of
swapping children.
4. Tabs — what ships and what doesn't¶
| Tab | Panel | Backing | Gate |
|---|---|---|---|
| Character | CharacterTab (new) |
ICharacterService, IJobService/TeamGroup — both already exist, plus two new Character fields (§5) |
None (self-service) |
Character select (not a tab — shown outside PlayerMenu, see §5.1) |
CharacterSelectPanel (Milestone 13) |
ICharacterService.GetSelectableCharactersAsync, CharacterNetworkComponent's new selection/deletion RPCs |
None (self-service; ownership enforced host-side) |
| Inventory | InventoryPanel (existing, reused as-is) |
CharacterInventoryComponent |
None |
| Laws | LawsTab (new) |
ILawService (already exists — §6) |
Read: none. Edit: law.edit |
| Info | InfoTab (new) |
StaticContentConfiguration (§7) |
None |
| Help | HelpTab (new) |
ICommandRegistry (already exists — §8) |
None |
| Changelog | ChangelogTab (new) |
StaticContentConfiguration (§9) |
None |
| Credits | CreditsTab (new) |
StaticContentConfiguration (§10) |
None |
InventoryPanel stays registered as a standing HUD element too
(Code/Inventories/InventoryModule.cs:94) — a player checking their bag mid-interaction
shouldn't need to open the full menu. The tab and the HUD element are the same Panel type,
registered against both registries; no duplication of markup.
Still deliberately not built as part of this menu: Store (legacy read GM.Items's
Cost/Batch/Store/Category fields — no purchasable/craftable model exists in
Code/Items/Code/Economy today; this is new domain design, not a wiring task, per the prior
design discussion) and Donate (implies a payment processor this codebase has no story for at
all — out of scope, not deferred scope). Help/Changelog/Credits were dropped in this
document's first draft on the same reasoning as Donate — reversed on direct instruction, since
unlike Donate all three have a real, cheap backing (§8–§10) once looked at properly. Clan
(legacy's per-character clan tag has no modeled equivalent anywhere in Code/Characters —
still out of scope until it does).
5. Character tab — confirmed fields, and the two new ones needed¶
Code/Characters/Character.cs reviewed directly (resolving this document's earlier "not
confirmed" caveat). Today Character holds exactly: Id (CharacterId), OwnerAccountId
(SteamId), Name, IsInitialised (internal-set lifecycle flag, not player-facing), and
Permissions (PermissionSet). Everything else a player perceives as part of their character
— money, inventory, needs, job — is deliberately not a Character field; it's owned by the
module that models it and reached through that module's own [Sync] Component, per
Character.cs's own header comment and
08_DATA_MODEL.md §1. CharacterTab reads those the normal
way each already documented panel does — Name and Permissions direct from Character,
job/faction from IJobService/TeamGroup (§4), nothing new needed for those.
Legacy's Details and Gender have no equivalent field anywhere in this codebase —
confirmed against both Character.cs and its persisted counterpart, CharacterDocument
(Code/Characters/CharacterStore.cs:267-273: Id, OwnerAccountId, Name,
IsInitialised, Permissions — the same five, nothing more). Reaching legacy parity needs
two new fields added to both types, plus a schema migration:
// Character.cs — new properties
public string Details { get; set; } = "";
public string Gender { get; set; } = "";
// CharacterStore.cs's CharacterDocument — mirrors the addition
public string Details { get; set; } = "";
public string Gender { get; set; } = "";
CharacterDocument is a persisted document (schemas.Register<CharacterDocument>(1),
Code/Characters/CharacterModule.cs) — adding fields to it is a schema change, not a free
edit, per 06_PERSISTENCE.md §4. This needs a version bump to
2 and an ISchemaMigration<CharacterDocument> from 1→2 defaulting both new fields to "" for
every already-persisted character — the same small, chained migration step
06_PERSISTENCE.md §4 already describes, not a direct
1→3-style leap.
With those two fields in place, a new CharacterCommandsModule (Applejack.Characters,
DependsOn: [CoreModule, CharacterModule, CommandsModule]) registers:
| Command | RequiredPermission |
Arguments | Execute |
|---|---|---|---|
/details <text...> |
null |
1 | Sets the caller's own Character.Details, persists via ICharacterService.SaveAsync |
/gender <male\|female> |
null |
1 | Sets the caller's own Character.Gender, same persistence path |
Corrected from this section's first draft, which called for CharacterModule itself to
register these two commands directly. That doesn't actually compile: CommandsModule.DependsOn
already includes CharacterModule (Code/Commands/CommandsModule.cs), so CharacterModule
depending back on CommandsModule to register /details//gender would be a module
dependency cycle. CharacterCommandsModule is a sibling module in the same
Applejack.Characters namespace instead — the same shape AdminModule's /grant//revoke
already use relative to Code/Characters/ (AdminModule depends on both CharacterModule and
CommandsModule, and isn't part of either), just for a dependency-cycle reason here rather
than AdminKickModule's test-exclusion reason. Caught while actually wiring the command, not
during this document's original design pass — fixed in the same commit as the code that found
it, per 00_CONSTITUTION.md.
Job/team change reuses the existing /team command (Code/Jobs/JobsModule.cs) — no new
surface needed there; CharacterTab just lists IJobService's jobs grouped by TeamGroup
(Officials/Civilians/Underground — the fixed three groups replacing legacy's open-ended
faction list) and fires /team <jobAssetId> per "Become" button, exactly like
DevMenuPanel.razor's existing Join button already does.
5.1 Milestone 13: multiple characters, biography, attributes, skills¶
CharacterTab gains a /biography <text...> self-service edit (CharacterCommandsModule,
same shape as /details), and read-only Attributes/Skills display sourced from the active
Character.Progression. Editing Attributes/Skills is deliberately not a player
self-service command this milestone -- see
25_CHARACTER_CREATION_AND_PROGRESSION.md §11
for why (no consumer or growth-mechanic decision exists yet to validate free-typed values
against); AdminModule gains /setattribute//setskill instead, gated by admin.moderate,
until Milestone 16 gives these a real mechanic.
A new panel, CharacterSelectPanel (Code/Characters/Ui/CharacterSelectPanel.razor), is the
real creation/selection/deletion screen ADR-0011 calls for -- distinct from CharacterTab,
since it must be reachable before a connection has an active character at all, not just as a
menu tab inside PlayerMenu (which itself currently has no consumer wired to open it
automatically either -- see Code/Movement/PlayerSpawnerComponent.cs's own header comment).
CharacterSelectRootComponent shows/hides it by polling
CharacterNetworkComponent.IsInitialised each frame, following AdminToolsRootComponent's
on-demand-panel precedent (§7) but reacting to session state instead of a keybind. See
25_CHARACTER_CREATION_AND_PROGRESSION.md §9-10
for the full shape and the new RequestSelectCharacter/RequestDeleteCharacter/
RequestReturnToSelection RPCs it drives.
6. Laws tab — no new backend surface¶
ILawService.GetLaws()/SetLawAsync (Code/Law/ILawService.cs) already model exactly what
the tab needs, including the law.edit permission gate and the 120-second edit cooldown. The
panel reads GetLaws() directly, host-side only — the same live-collection carve-out
AdminPanel/DevMenuPanel already use for ICharacterService.GetAllActive()
(11_UI_ARCHITECTURE.md §7) — and an
edit box per slot fires the existing /setlaw <index> <text> command through
CommandDispatchComponent. Open UX question, not solved here: the cooldown's remaining
time isn't exposed anywhere a panel could read it without a new query surface, so a rejected
edit (cooldown not yet elapsed) can show "edit failed," not "edit failed, N seconds left" —
acceptable for a first pass, named so it isn't mistaken for an oversight later.
7. Info, Changelog, and Credits — one shared content resource¶
Legacy's [Welcome]/[Rules]/[Credits] tabs (cl_welcome.lua, cl_rules.lua,
cl_credits.lua) and the changelog were all string-per-heading text blocks with no
interactivity. Simplest option that avoids inventing a content-authoring system this project
doesn't otherwise have: a single StaticContentConfiguration : GameResource (following
CharacterConfiguration's own precedent) holding one string field per static tab, loaded once
at boot, rendered read-only — no new module, sits in Code/Ui/ alongside the registries,
since nothing else needs it:
namespace Applejack.Ui;
[GameResource("Static Content", "staticcontent", "Read-only text shown in the player menu's Info/Changelog/Credits tabs.")]
public sealed class StaticContentConfiguration : GameResource
{
[Property] public string WelcomeText { get; set; } = "";
[Property] public string RulesText { get; set; } = "";
[Property] public string ChangelogText { get; set; } = "";
[Property] public string CreditsText { get; set; } = "";
}
This is the smallest option that closes all three tabs, not a mandated design — open to a richer per-section format later if a flat string per tab proves awkward to author.
Info tab renders WelcomeText then RulesText, one after the other — legacy kept these
as separate tabs; folding them costs nothing since both are static, and a player opening
"Info" reads both in one place instead of hunting across two near-identical tabs.
8. Help tab — reuses ICommandRegistry as-is¶
ICommandRegistry.All (Code/Commands/ICommandRegistry.cs) already carries everything
legacy's static Help tab had to hand-author: Name, Usage, Category, HelpText per
registered command — CommandsModule's own /help proves this data is already complete
enough to list (Code/Commands/CommandsModule.cs). HelpTab groups All by Category and
renders Usage/HelpText per row — no new backend surface, and unlike a hand-written help
page it can never drift out of date with what commands actually exist, since it reads the
same registry /help itself reads.
9. Changelog tab¶
Renders StaticContentConfiguration.ChangelogText as-is (§7) — one entry per release,
newline-separated, the same flat-text treatment as Info. No automated generation from git
history or ROADMAP.md milestones is proposed here; keeping it a manually-authored field
avoids coupling player-facing copy to commit-message wording, which is written for a different
audience.
10. Credits tab¶
Renders StaticContentConfiguration.CreditsText (§7). Content, not code — authored directly
into the asset, not hardcoded into a panel — but recorded here since it was specified
explicitly rather than left as a placeholder:
[Original Applejack]
kuromeku — original creator of Applejack (Cider)
Lexi — further development
[Applejack — S&box rewrite]
fobiat — rewrite and modernisation (fobiat.github.io)
[Engine]
Built on S&box by Facepunch Studios
This mirrors README.md's own Credits section, which carries the same attribution — the two should be kept in sync by hand going forward, same as any other duplicated fact this codebase doesn't have a single source of truth for yet.
11. Reactivity¶
Every new tab follows 11_UI_ARCHITECTURE.md §2's rule:
BuildHash() includes every value the markup reads. CharacterTab combines the caller's own
Character state with IJobService.All.Count; LawsTab hashes GetLaws()'s ten slots;
HelpTab hashes ICommandRegistry.All.Count (registration is boot-time-only today, so this
never actually changes post-boot, but costs nothing to include); InfoTab/ChangelogTab/
CreditsTab each return a constant (static content loaded once, never rebuilt — same
reasoning HudRoot.razor's own BuildHash() => 0 already documents for static structure).
12. Unverified engine assumptions (named, not faked)¶
Same practice as ROADMAP.md § Verification debt and 22_PLAYER_PRESENCE_AND_ADMIN_TOOLS.md §5:
@refpanel-reference capture andPanel.DeleteChildren()(§3).- Whether
onclick="@(() => Select(tab.Title))"correctly closes over a loop variable across Razor's own diffing —AdminPanel.razor's per-character button loop makes the same unverified assumption already. - Whether swapping the
player-menu-contentcontainer's child on tab select, rather than toggling pre-built children's visibility (HudRoot's approach), causes any per-frame cost or flicker — untested without a running client.
13. Testing¶
MenuRegistry's bookkeeping is trivially correct, same as HudRegistry's
(Code/Ui/README.md's own "Not unit-tested" reasoning applies identically here — a
where TPanel : Panel, new() constraint needs a real engine type to even call it).
CharacterTab's job-grouping-by-TeamGroup logic, LawsTab's slot-formatting logic, and
HelpTab's grouping-by-Category logic should each be extractable into a plain method taking
data and returning a view model, per
11_UI_ARCHITECTURE.md §6 — unit-tested the same way the
rest of this codebase's engine-agnostic core already is. The new Character.Details/Gender
schema migration (§5) gets its own test, following CharacterStore's existing migration test
pattern for schema version 1's own fields.